MotorSignal-App/test/completed_ride_sync_recover...

327 lines
11 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
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/sync/sync_models.dart';
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
class _TestAuthService implements AuthService {
AuthUser? _currentUser;
AuthState _authState = const AuthState.unauthenticated();
void setAuthenticatedUser(String uid, String email) {
_currentUser = AuthUser(
uid: uid,
email: email,
displayName: 'Test Rider',
isAnonymous: false,
);
_authState = AuthState.authenticated(_currentUser!);
}
void setGuestUser() {
_currentUser = const AuthUser(
uid: 'guest_uid_123',
email: null,
displayName: 'Guest Rider',
isAnonymous: true,
);
_authState = AuthState.anonymous(_currentUser!);
}
void setLoggedOut() {
_currentUser = null;
_authState = const AuthState.unauthenticated();
}
@override
AuthUser? get currentUser => _currentUser;
@override
AuthState get currentState => _authState;
@override
Stream<AuthState> get authStateChanges => Stream.value(_authState);
@override
bool get isConfigured => true;
@override
bool get isEmailVerified => false;
@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_id_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 => setGuestUser();
@override
Future<void> signInWithEmailAndPassword(String email, String password) async =>
setAuthenticatedUser('uid_123', email);
@override
Future<void> signInWithGoogle() async =>
setAuthenticatedUser('uid_google', 'google@example.com');
@override
Future<void> signOut() async => setLoggedOut();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('v1.1 Completed-Ride Sync Outbox Recovery Tests', () {
late SharedPreferences prefs;
late AppDatabase db;
late _TestAuthService authService;
late FakeSyncProvider fakeProvider;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = AppDatabase.forTesting(NativeDatabase.memory());
authService = _TestAuthService();
fakeProvider = FakeSyncProvider(providerId: 'firebase');
});
tearDown(() async {
await db.close();
});
test('1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', () async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
// Simulate a completed ride committed to SQLite during a previous session
final rideId = await db.startRide(
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
motorcycleMake: 'Honda',
motorcycleModel: 'CBR600RR',
);
await db.endRide(rideId, const RidesCompanion());
// Confirm outbox starts empty (app terminated before enqueue ran)
final outbox = SyncOutbox(prefs);
expect(outbox.count, equals(0));
// Startup initialization of SyncManager (testing step-by-step)
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// Reconcile runs
final report = await manager.reconcileCompletedRides();
expect(report.discoveredCompletedRides, equals(1));
expect(report.recoveredQueuedCount, equals(1));
expect(report.alreadyTrackedCount, equals(0));
expect(outbox.count, equals(1));
final item = outbox.getItem(rideId, 'firebase');
expect(item, isNotNull);
expect(item?.status, equals(SyncStatus.queued));
// Verify ride ownership was claimed for authenticated user
final ownerUid = await db.getCloudOwnerUid(rideId);
expect(ownerUid, equals('rider_alice'));
// Process queue to complete upload
await manager.processQueue();
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
expect(fakeProvider.uploadedItems.length, equals(1));
});
test('2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads', () async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 11, 0, 0));
await db.endRide(rideId, const RidesCompanion());
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// First run reconciles and queues the ride
final report1 = await manager.reconcileCompletedRides();
expect(report1.recoveredQueuedCount, equals(1));
expect(outbox.count, equals(1));
// Process upload
await manager.processQueue();
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
expect(fakeProvider.uploadedItems.length, equals(1));
// Second run reports ride as already tracked, zero duplicates
final report2 = await manager.reconcileCompletedRides();
expect(report2.discoveredCompletedRides, equals(1));
expect(report2.alreadyTrackedCount, equals(1));
expect(report2.recoveredQueuedCount, equals(0));
expect(report2.totalRecovered, equals(0));
expect(outbox.count, equals(1));
// Running processQueue again does not re-upload
await manager.processQueue();
expect(fakeProvider.uploadedItems.length, equals(1));
});
test('3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', () async {
// User is initially a guest and provider requires auth
authService.setGuestUser();
fakeProvider.behavior = FakeSyncBehavior.authRequired;
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 12, 0, 0));
await db.endRide(rideId, const RidesCompanion());
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
autoStart: false,
);
// Reconcile as guest -> authRequired
final report = await manager.reconcileCompletedRides();
expect(report.recoveredAuthRequiredCount, equals(1));
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.authRequired));
// Ownership remains unclaimed
expect(await db.getCloudOwnerUid(rideId), isNull);
// Guest now signs in with Google / Email
authService.setAuthenticatedUser('rider_bob', 'bob@example.com');
fakeProvider.behavior = FakeSyncBehavior.success;
// Invoking retryAuthRequired claims the ride and triggers queue upload
await manager.retryAuthRequired();
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
expect(fakeProvider.uploadedItems.length, equals(1));
});
test('4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle', () async {
authService.setAuthenticatedUser('rider_charlie', 'charlie@example.com');
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 13, 0, 0));
await db.endRide(rideId, const RidesCompanion());
final remoteRideId = await db.ensureCloudRideId(rideId);
// Simulate that cloud backend already has this ride bundle uploaded
fakeProvider.confirmedRemoteRideIds.add(remoteRideId);
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.recoveredAlreadySyncedCount, equals(1));
expect(report.recoveredQueuedCount, equals(0));
final item = outbox.getItem(rideId, 'firebase');
expect(item?.status, equals(SyncStatus.synced));
expect(item?.remoteId, equals(remoteRideId));
// Zero upload attempts because it was already confirmed
expect(fakeProvider.uploadedItems.isEmpty, isTrue);
});
test('5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', () async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
// Create ride explicitly owned by rider_bob
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
cloudRideId: const drift.Value('ride_bob_123'),
cloudOwnerUid: const drift.Value('rider_bob'),
startTime: DateTime.utc(2026, 10, 12, 14, 0, 0),
endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)),
));
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.foreignOwnerSkippedCount, equals(1));
expect(report.totalRecovered, equals(0));
expect(outbox.count, equals(0));
// Ownership in db remains unchanged
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
});
test('6. In-Progress Isolation: Active rides without endTime are never recovered as completed', () async {
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
// Start ride but do not end it (endTime is null)
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 16, 0, 0));
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [fakeProvider],
defaultProviderId: 'firebase',
db: db,
authService: authService,
);
final report = await manager.reconcileCompletedRides();
expect(report.discoveredCompletedRides, equals(0));
expect(report.totalRecovered, equals(0));
expect(outbox.getItem(rideId, 'firebase'), isNull);
});
});
}