794 lines
26 KiB
Dart
794 lines
26 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);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'7. Inconsistent Cloud Confirmation: Preserves local data and reports inconsistent skipped without overwriting',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 17, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
final remoteRideId = await db.ensureCloudRideId(rideId);
|
|
|
|
fakeProvider.confirmationResults[remoteRideId] =
|
|
CloudConfirmationResult.inconsistent(
|
|
remoteRideId: remoteRideId,
|
|
message: 'SHA-256 mismatch detected in storage',
|
|
);
|
|
|
|
final outbox = SyncOutbox(prefs);
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
);
|
|
|
|
final report = await manager.reconcileCompletedRides();
|
|
expect(report.inconsistentSkippedCount, equals(1));
|
|
expect(report.recoveredAlreadySyncedCount, equals(0));
|
|
|
|
final item = outbox.getItem(rideId, 'firebase');
|
|
expect(item, isNotNull);
|
|
expect(item?.status, equals(SyncStatus.failed));
|
|
expect(item?.lastError, contains('inconsistent'));
|
|
|
|
// Local ride remains safe in SQLite
|
|
final localRide = await (db.select(
|
|
db.rides,
|
|
)..where((r) => r.id.equals(rideId))).getSingle();
|
|
expect(localRide.cloudRideId, equals(remoteRideId));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'8. Temporarily Unverifiable State: Leaves ride in retryWait with backoff without marking permanently missing',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 18, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
final remoteRideId = await db.ensureCloudRideId(rideId);
|
|
|
|
fakeProvider.confirmationResults[remoteRideId] =
|
|
CloudConfirmationResult.temporarilyUnverifiable(
|
|
remoteRideId: remoteRideId,
|
|
message: 'HTTP 503 backend gateway timeout',
|
|
);
|
|
|
|
final outbox = SyncOutbox(prefs);
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
);
|
|
|
|
final report = await manager.reconcileCompletedRides();
|
|
expect(report.unverifiablePendingCount, equals(1));
|
|
expect(report.recoveredAlreadySyncedCount, equals(0));
|
|
|
|
final item = outbox.getItem(rideId, 'firebase');
|
|
expect(item, isNotNull);
|
|
expect(item?.status, equals(SyncStatus.retryWait));
|
|
expect(item?.nextRetryAt, isNotNull);
|
|
expect(item?.lastError, contains('temporarily unverifiable'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'9. Pre-Upload Ownership Protection: processQueue cancels foreign-owned outbox item before calling provider',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
// Create ride owned by rider_bob
|
|
final rideId = await db
|
|
.into(db.rides)
|
|
.insert(
|
|
RidesCompanion.insert(
|
|
cloudRideId: const drift.Value('ride_bob_456'),
|
|
cloudOwnerUid: const drift.Value('rider_bob'),
|
|
startTime: DateTime.utc(2026, 10, 12, 19, 0, 0),
|
|
endTime: drift.Value(DateTime.utc(2026, 10, 12, 20, 0, 0)),
|
|
),
|
|
);
|
|
|
|
// Force an outbox item into the queue (e.g., from stale shared storage)
|
|
final outbox = SyncOutbox(prefs);
|
|
await outbox.update(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
status: SyncStatus.queued,
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
autoStart: false,
|
|
);
|
|
|
|
await manager.processQueue();
|
|
|
|
final item = outbox.getItem(rideId, 'firebase');
|
|
expect(item?.status, equals(SyncStatus.cancelled));
|
|
expect(item?.lastError, contains('belongs to another account'));
|
|
expect(
|
|
fakeProvider.uploadedItems.isEmpty,
|
|
isTrue,
|
|
reason: 'Must not call provider upload for foreign ride',
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'10. Mid-flight Auth Change Protection: Reverts upload to authRequired if user logs out mid-flight',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 21, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
await db.claimRideForCloudOwner(rideId, 'rider_alice');
|
|
|
|
final outbox = SyncOutbox(prefs);
|
|
await outbox.update(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
status: SyncStatus.queued,
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
// Provider simulates delay and during delay user logs out
|
|
fakeProvider.delayMs = 50;
|
|
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
autoStart: false,
|
|
);
|
|
|
|
// Start processQueue
|
|
final uploadFuture = manager.processQueue();
|
|
|
|
// Mid-flight logout
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
authService.setLoggedOut();
|
|
|
|
await uploadFuture;
|
|
|
|
final item = outbox.getItem(rideId, 'firebase');
|
|
expect(item?.status, equals(SyncStatus.authRequired));
|
|
expect(
|
|
item?.lastError,
|
|
contains('User changed or logged out during upload'),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'11. Concurrency Protection: Simultaneous reconcileCompletedRides join the same future',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 22, 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,
|
|
);
|
|
|
|
final results = await Future.wait([
|
|
manager.reconcileCompletedRides(),
|
|
manager.reconcileCompletedRides(),
|
|
]);
|
|
|
|
expect(results[0].discoveredCompletedRides, equals(1));
|
|
expect(results[1].discoveredCompletedRides, equals(1));
|
|
expect(outbox.count, equals(1));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'12. Stale Persisted Outbox on retryAllPending: Foreign-owned items are cancelled on retry',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
final rideId = await db
|
|
.into(db.rides)
|
|
.insert(
|
|
RidesCompanion.insert(
|
|
cloudRideId: const drift.Value('ride_bob_789'),
|
|
cloudOwnerUid: const drift.Value('rider_bob'),
|
|
startTime: DateTime.utc(2026, 10, 12, 23, 0, 0),
|
|
endTime: drift.Value(DateTime.utc(2026, 10, 13, 0, 0, 0)),
|
|
),
|
|
);
|
|
|
|
final outbox = SyncOutbox(prefs);
|
|
await outbox.update(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
status: SyncStatus.failed,
|
|
lastError: 'Previous failure',
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
autoStart: false,
|
|
);
|
|
|
|
await manager.retryAllPending();
|
|
|
|
final item = outbox.getItem(rideId, 'firebase');
|
|
expect(item?.status, equals(SyncStatus.cancelled));
|
|
expect(item?.lastError, contains('belongs to another account'));
|
|
expect(fakeProvider.uploadedItems.isEmpty, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'13. In-Flight Cancellation Protection: cancelSync preserves cancelled status even after upload completes',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
await db.claimRideForCloudOwner(rideId, 'rider_alice');
|
|
|
|
final outbox = SyncOutbox(prefs);
|
|
await outbox.update(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
status: SyncStatus.queued,
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
// Simulate provider with 50ms upload latency
|
|
fakeProvider.delayMs = 50;
|
|
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
autoStart: false,
|
|
);
|
|
|
|
// Start processing queue
|
|
final processFuture = manager.processQueue();
|
|
|
|
// While upload is in flight, user cancels sync
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
await manager.cancelSync(rideId, 'firebase');
|
|
|
|
await processFuture;
|
|
|
|
final item = outbox.getItem(rideId, 'firebase');
|
|
expect(item?.status, equals(SyncStatus.cancelled),
|
|
reason: 'Item must remain cancelled and not get overwritten with synced');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'14. Default Active Provider: Selects defaultProviderId or first initial provider, defaulting to fake for bare test manager',
|
|
() async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final managerExplicit = SyncManager(
|
|
outbox,
|
|
defaultProviderId: 'firebase',
|
|
autoStart: false,
|
|
);
|
|
expect(managerExplicit.activeProviderId, equals('firebase'));
|
|
|
|
final customProvider = FakeSyncProvider(providerId: 'custom_gateway');
|
|
final managerWithProviders = SyncManager(
|
|
outbox,
|
|
initialProviders: [customProvider],
|
|
autoStart: false,
|
|
);
|
|
expect(managerWithProviders.activeProviderId, equals('custom_gateway'));
|
|
|
|
final managerNoProviders = SyncManager(
|
|
outbox,
|
|
autoStart: false,
|
|
);
|
|
expect(managerNoProviders.activeProviderId, equals('fake'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'15. Immediate Enqueue Ownership Protection: enqueueRide cancels foreign-owned rides immediately',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
|
|
|
final rideId = await db
|
|
.into(db.rides)
|
|
.insert(
|
|
RidesCompanion.insert(
|
|
cloudRideId: const drift.Value('ride_bob_999'),
|
|
cloudOwnerUid: const drift.Value('rider_bob'),
|
|
startTime: DateTime.utc(2026, 10, 12, 11, 0, 0),
|
|
endTime: drift.Value(DateTime.utc(2026, 10, 12, 12, 0, 0)),
|
|
),
|
|
);
|
|
|
|
final outbox = SyncOutbox(prefs);
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
autoStart: false,
|
|
);
|
|
|
|
final item = await manager.enqueueRide(rideId, autoProcess: false);
|
|
expect(item.status, equals(SyncStatus.cancelled));
|
|
expect(item.lastError, contains('belongs to another account'));
|
|
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.cancelled));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'16. Failed Cloud Verification followed by Successful Retry: Recovers to synced without re-upload',
|
|
() async {
|
|
authService.setAuthenticatedUser('rider_alice', 'alice@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);
|
|
|
|
// Step 1: Initial reconciliation fails with temporary verification error (e.g. HTTP 503)
|
|
fakeProvider.confirmationResults[remoteRideId] =
|
|
CloudConfirmationResult.temporarilyUnverifiable(
|
|
remoteRideId: remoteRideId,
|
|
message: 'HTTP 503 gateway timeout',
|
|
);
|
|
|
|
final outbox = SyncOutbox(prefs);
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [fakeProvider],
|
|
defaultProviderId: 'firebase',
|
|
db: db,
|
|
authService: authService,
|
|
autoStart: false,
|
|
);
|
|
|
|
final report1 = await manager.reconcileCompletedRides();
|
|
expect(report1.unverifiablePendingCount, equals(1));
|
|
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.retryWait));
|
|
|
|
// Step 2: Backend recovers and verifies the bundle in R2
|
|
fakeProvider.confirmationResults[remoteRideId] =
|
|
CloudConfirmationResult.confirmed(remoteRideId: remoteRideId);
|
|
fakeProvider.confirmedRemoteRideIds.add(remoteRideId);
|
|
|
|
// Process queue on retry
|
|
await manager.retryAllPending();
|
|
|
|
expect(
|
|
outbox.getItem(rideId, 'firebase')?.status,
|
|
equals(SyncStatus.synced),
|
|
);
|
|
expect(fakeProvider.uploadedItems.isEmpty, isTrue,
|
|
reason: 'Must confirm and mark synced without re-uploading the bundle');
|
|
},
|
|
);
|
|
});
|
|
}
|