MotorSignal-App/test/motorcycle_cross_device_rec...

230 lines
7.8 KiB
Dart

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<AuthState> get authStateChanges => Stream.value(currentState);
@override
bool get isConfigured => true;
@override
bool get isEmailVerified => true;
@override
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
@override
Future<void> deleteCurrentUser() async {}
@override
void dispose() {}
@override
Future<void> ensureBackendIdentity() async {}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake-token';
@override
Future<void> reauthenticate({String? password}) async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> 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<String, dynamic>;
expect(decoded['displacementCc'], 890);
expect(decoded['curbWeightKg'], 193);
expect(decoded['fuelCapacityL'], 14.0);
expect(decoded['cloudId'], 'moto_cloud_99');
});
});
}