230 lines
7.8 KiB
Dart
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');
|
|
});
|
|
});
|
|
}
|