From 68c0bda344029485b3fa5827fa1877051be72bfa Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 00:25:00 -0400 Subject: [PATCH] fix: assign stable cloud ride identity at creation --- lib/database/database.dart | 21 ++++- lib/features/rides/ride_state_machine.dart | 19 ++--- test/cloud_ride_identity_migration_test.dart | 16 ++++ test/forensic_lifecycle_audit_test.dart | 81 ++++++++++++++++++++ 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/lib/database/database.dart b/lib/database/database.dart index 2e5c343..00fc344 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -142,10 +142,23 @@ class AppDatabase extends _$AppDatabase { return created; } - Future startRide() async { - final id = await into(rides).insert(RidesCompanion.insert(startTime: DateTime.now())); - await ensureCloudRideId(id); - return id; + Future startRide({ + DateTime? startTime, + int? motorcycleId, + int? motorcycleYear, + String? motorcycleMake, + String? motorcycleModel, + String? motorcycleVariant, + }) async { + return into(rides).insert(RidesCompanion.insert( + cloudRideId: Value(_newCloudRideId()), + startTime: startTime ?? DateTime.now(), + motorcycleId: Value(motorcycleId), + motorcycleYear: Value(motorcycleYear), + motorcycleMake: Value(motorcycleMake), + motorcycleModel: Value(motorcycleModel), + motorcycleVariant: Value(motorcycleVariant), + )); } Future endRide(int rideId, RidesCompanion data) async { diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index a57771c..ddce8be 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -211,20 +211,21 @@ class RideStateMachine extends StateNotifier { } catch (_) {} final defaultBike = ref.read(defaultMotorcycleProvider); - final id = await db.into(db.rides).insert(RidesCompanion.insert( - startTime: DateTime.now(), - motorcycleId: drift.Value(defaultBike?.id), - motorcycleYear: drift.Value(defaultBike?.year), - motorcycleMake: drift.Value(defaultBike?.makeName), - motorcycleModel: drift.Value(defaultBike?.modelName), - motorcycleVariant: drift.Value(defaultBike?.variantName), - )); + final startTime = DateTime.now(); + final id = await db.startRide( + startTime: startTime, + motorcycleId: defaultBike?.id, + motorcycleYear: defaultBike?.year, + motorcycleMake: defaultBike?.makeName, + motorcycleModel: defaultBike?.modelName, + motorcycleVariant: defaultBike?.variantName, + ); state = state.copyWith( dbId: id, state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive, - startTime: DateTime.now(), + startTime: startTime, tripDistanceMiles: 0, topSpeedMph: 0, maxLeanLeft: 0, diff --git a/test/cloud_ride_identity_migration_test.dart b/test/cloud_ride_identity_migration_test.dart index 0691269..59cab19 100644 --- a/test/cloud_ride_identity_migration_test.dart +++ b/test/cloud_ride_identity_migration_test.dart @@ -35,6 +35,22 @@ void main() { expect(second, first); }); + test('ensureCloudRideId repairs legacy null ride identity', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + + final legacyRideId = await db.into(db.rides).insert( + RidesCompanion.insert(startTime: DateTime.utc(2026, 9, 1, 10)), + ); + expect(await db.getCloudRideId(legacyRideId), isNull); + + final repaired = await db.ensureCloudRideId(legacyRideId); + + expect(repaired, startsWith('ride_')); + expect(await db.getCloudRideId(legacyRideId), repaired); + expect(await db.ensureCloudRideId(legacyRideId), repaired); + }); + test('real v5 rides migrate to their legacy MotorCloud identity', () async { final connection = NativeDatabase.memory(setup: (rawDb) { rawDb.execute(''' diff --git a/test/forensic_lifecycle_audit_test.dart b/test/forensic_lifecycle_audit_test.dart index 1302c73..aeaf565 100644 --- a/test/forensic_lifecycle_audit_test.dart +++ b/test/forensic_lifecycle_audit_test.dart @@ -16,6 +16,7 @@ import 'package:motorcycle_dash/services/field_test/field_test_logger.dart'; import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/features/garage/garage_providers.dart'; class MockAudioAlertService extends Fake implements AudioAlertService { final List spokenMessages = []; @@ -177,6 +178,86 @@ void main() { expect(events.any((e) => e.eventType.contains('START') || e.eventType.contains('RIDE')), isTrue); }); + test('RideStateMachine assigns stable cloud identity at ride creation', () async { + final bike = GarageMotorcycle( + id: 7, + year: 2024, + makeId: 'triumph', + makeName: 'Triumph', + modelId: 'street-triple', + modelName: 'Street Triple', + variantName: 'RS', + isDefault: true, + dateAdded: DateTime.utc(2026, 9, 1), + lastUpdated: DateTime.utc(2026, 9, 1), + ); + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + databaseProvider.overrideWithValue(db), + telemetryServiceProvider.overrideWithValue(fakeTelemetry), + audioAlertServiceProvider.overrideWithValue(mockAudio), + fieldTestLoggerProvider.overrideWithValue(logger), + defaultMotorcycleProvider.overrideWithValue(bike), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(rideStateMachineProvider.notifier); + await notifier.startRide(); + + final session = container.read(rideStateMachineProvider); + expect(session.dbId, isNotNull); + + final ride = await (db.select(db.rides)..where((r) => r.id.equals(session.dbId!))).getSingle(); + expect(ride.cloudRideId, isNotNull); + expect(ride.cloudRideId, startsWith('ride_')); + expect(ride.motorcycleId, bike.id); + expect(ride.motorcycleYear, bike.year); + expect(ride.motorcycleMake, bike.makeName); + expect(ride.motorcycleModel, bike.modelName); + expect(ride.motorcycleVariant, bike.variantName); + + final ensured = await db.ensureCloudRideId(ride.id); + expect(ensured, ride.cloudRideId); + expect(await db.getCloudRideId(ride.id), ride.cloudRideId); + }); + + test('Interrupted ride recovery preserves existing cloud identity', () async { + final rideId = await db.startRide(startTime: DateTime.utc(2026, 9, 2, 12)); + final cloudRideId = await db.getCloudRideId(rideId); + expect(cloudRideId, isNotNull); + + await db.into(db.telemetrySamples).insert( + TelemetrySamplesCompanion.insert( + rideId: rideId, + timestamp: DateTime.utc(2026, 9, 2, 12, 5), + ), + ); + + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + databaseProvider.overrideWithValue(db), + telemetryServiceProvider.overrideWithValue(fakeTelemetry), + audioAlertServiceProvider.overrideWithValue(mockAudio), + fieldTestLoggerProvider.overrideWithValue(logger), + ], + ); + addTearDown(container.dispose); + + container.read(rideStateMachineProvider.notifier); + await Future.delayed(const Duration(milliseconds: 50)); + + final recovered = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle(); + expect( + recovered.endTime!.isAtSameMomentAs(DateTime.utc(2026, 9, 2, 12, 5)), + isTrue, + ); + expect(recovered.cloudRideId, cloudRideId); + expect(await db.ensureCloudRideId(rideId), cloudRideId); + }); + test('Motion Safeguard: Moving vehicle auto-promotes READY state to active recording', () async { final container = ProviderContainer( overrides: [