import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/database/database.dart'; import 'package:motorcycle_dash/services/auth/auth_service.dart'; import 'package:motorcycle_dash/services/auth/auth_state.dart'; import 'package:motorcycle_dash/services/auth/auth_user.dart'; import 'package:motorcycle_dash/services/cloud/firebase_account_sync_service.dart'; class _FakeAuthService implements AuthService { final AuthUser _user; _FakeAuthService(this._user); @override AuthUser? get currentUser => _user; @override AuthState get currentState => AuthState.authenticated(_user); @override Stream get authStateChanges => Stream.value(currentState); @override bool get isConfigured => true; @override bool get isEmailVerified => true; @override Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} @override Future deleteCurrentUser() async {} @override void dispose() {} @override Future ensureBackendIdentity() async {} @override Future getIdToken([bool forceRefresh = false]) async => 'fake-token'; @override Future reauthenticate({String? password}) async {} @override Future reloadCurrentUser() async {} @override Future sendEmailVerification() async {} @override Future sendPasswordResetEmail(String email) async {} @override Future signInAnonymously() async {} @override Future signInWithEmailAndPassword(String email, String password) async {} @override Future signInWithGoogle() async {} @override Future signOut() async {} } void main() { const testUser = AuthUser( uid: 'user_cross_dev_123', email: 'rider@example.com', displayName: 'Test Rider', ); group('Motorcycle Cross-Device Reconciliation (Codex Finding 5)', () { late FakeFirebaseFirestore fakeFirestore; late _FakeAuthService auth; setUp(() { fakeFirestore = FakeFirebaseFirestore(); auth = _FakeAuthService(testUser); }); test('reconciles different motorcycles on two devices with identical local ID 1 without clobbering', () async { // Device A DB final dbA = AppDatabase.forTesting(NativeDatabase.memory()); addTearDown(dbA.close); // Device B DB final dbB = AppDatabase.forTesting(NativeDatabase.memory()); addTearDown(dbB.close); final now = DateTime.utc(2026, 10, 9, 12, 0, 0); // Device A has local bike with id=1: Yamaha MT-09 await dbA.into(dbA.garageMotorcycles).insert( GarageMotorcyclesCompanion( id: const Value(1), year: const Value(2024), makeId: const Value('10'), makeName: const Value('Yamaha'), modelId: const Value('100'), modelName: const Value('MT-09'), dateAdded: Value(now), lastUpdated: Value(now), ), ); // Device B has local bike with id=1: Ducati Monster (independent device, same local ID 1) await dbB.into(dbB.garageMotorcycles).insert( GarageMotorcyclesCompanion( id: const Value(1), year: const Value(2023), makeId: const Value('20'), makeName: const Value('Ducati'), modelId: const Value('200'), modelName: const Value('Monster'), dateAdded: Value(now), lastUpdated: Value(now), ), ); final syncA = FirebaseAccountSyncService( db: dbA, auth: auth, firestore: fakeFirestore, ); final syncB = FirebaseAccountSyncService( db: dbB, auth: auth, firestore: fakeFirestore, ); // --- Step 1: Device A syncs --- await syncA.syncAccount(now: now); // Verify Firestore has Yamaha under unique cloud ID final cloudBikesAfterA = await fakeFirestore .collection('users') .doc(testUser.uid) .collection('motorcycles') .get(); expect(cloudBikesAfterA.docs.length, 1); final bikeADoc = cloudBikesAfterA.docs.first; expect(bikeADoc.data()['makeName'], 'Yamaha'); expect(bikeADoc.data()['modelName'], 'MT-09'); expect(bikeADoc.id, contains('yamaha')); // Verify Device A persisted cloudId in local specificationJson final localBikesA = await dbA.select(dbA.garageMotorcycles).get(); expect(localBikesA.length, 1); expect(localBikesA.first.id, 1); final cloudIdA = FirebaseAccountSyncService.extractCloudId(localBikesA.first.specificationJson); expect(cloudIdA, equals(bikeADoc.id)); // --- Step 2: Device B syncs --- await syncB.syncAccount(now: now); // Verify Device B local state: // Local ID 1 must still be Ducati Monster! Yamaha MT-09 must NOT have overwritten ID 1! final localBikesB = await dbB.select(dbB.garageMotorcycles).get(); expect(localBikesB.length, 2, reason: 'Device B must now have both motorcycles locally'); final ducatiOnB = localBikesB.firstWhere((b) => b.modelName == 'Monster'); final yamahaOnB = localBikesB.firstWhere((b) => b.modelName == 'MT-09'); expect(ducatiOnB.id, 1, reason: 'Original Ducati on Device B retains local ID 1'); expect(ducatiOnB.makeName, 'Ducati'); expect(yamahaOnB.id, isNot(1), reason: 'Synced Yamaha on Device B gets a fresh local auto-increment ID'); // Verify Firestore state after Device B sync: // Both motorcycles must be present in Firestore. Ducati must NOT have overwritten Yamaha! final cloudBikesAfterB = await fakeFirestore .collection('users') .doc(testUser.uid) .collection('motorcycles') .get(); expect(cloudBikesAfterB.docs.length, 2, reason: 'Firestore must contain both motorcycles'); final cloudMakes = cloudBikesAfterB.docs.map((d) => d.data()['makeName']).toSet(); expect(cloudMakes, containsAll(['Yamaha', 'Ducati'])); // --- Step 3: Device A syncs again --- await syncA.syncAccount(now: now); // Device A should now also safely pull in Ducati without clobbering Yamaha ID 1 final localBikesASecond = await dbA.select(dbA.garageMotorcycles).get(); expect(localBikesASecond.length, 2, reason: 'Device A now also has both motorcycles'); final yamahaOnA = localBikesASecond.firstWhere((b) => b.modelName == 'MT-09'); final ducatiOnA = localBikesASecond.firstWhere((b) => b.modelName == 'Monster'); expect(yamahaOnA.id, 1, reason: 'Original Yamaha on Device A retains local ID 1'); expect(ducatiOnA.id, isNot(1), reason: 'Synced Ducati on Device A gets a fresh local auto-increment ID'); // --- Step 4: Idempotency / No Duplication --- await syncA.syncAccount(now: now); await syncB.syncAccount(now: now); expect((await dbA.select(dbA.garageMotorcycles).get()).length, 2); expect((await dbB.select(dbB.garageMotorcycles).get()).length, 2); expect( (await fakeFirestore.collection('users').doc(testUser.uid).collection('motorcycles').get()).docs.length, 2, ); }); test('extractCloudId and attachCloudId preserve existing specification metadata', () { final initialSpec = json.encode({ 'displacementCc': 890, 'curbWeightKg': 193, 'fuelCapacityL': 14.0, }); final attached = FirebaseAccountSyncService.attachCloudId(initialSpec, 'moto_cloud_99'); expect(FirebaseAccountSyncService.extractCloudId(attached), 'moto_cloud_99'); final decoded = json.decode(attached) as Map; expect(decoded['displacementCc'], 890); expect(decoded['curbWeightKg'], 193); expect(decoded['fuelCapacityL'], 14.0); expect(decoded['cloudId'], 'moto_cloud_99'); }); }); }