416 lines
16 KiB
Dart
416 lines
16 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/models/recording_health_status.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_bundle_worker.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_observer.dart';
|
|
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
|
|
|
|
class TestMockAuthService implements AuthService {
|
|
@override bool get isEmailVerified => false;
|
|
@override Future<void> sendEmailVerification() async {}
|
|
@override Future<void> reloadCurrentUser() async {}
|
|
@override Future<void> reauthenticate({String? password}) async {}
|
|
@override Future<void> deleteCurrentUser() async {}
|
|
@override Future<void> ensureBackendIdentity() async {}
|
|
AuthUser? _user;
|
|
AuthState _state = const AuthState.unauthenticated();
|
|
|
|
void setUser(AuthUser? user) {
|
|
_user = user;
|
|
if (user != null) {
|
|
_state = user.isAnonymous
|
|
? AuthState.anonymous(user)
|
|
: AuthState.authenticated(user);
|
|
} else {
|
|
_state = const AuthState.unauthenticated();
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool get isConfigured => true;
|
|
|
|
@override
|
|
AuthUser? get currentUser => _user;
|
|
|
|
@override
|
|
AuthState get currentState => _state;
|
|
|
|
@override
|
|
Stream<AuthState> get authStateChanges => Stream.value(_state);
|
|
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
|
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {}
|
|
|
|
@override
|
|
Future<void> signInAnonymously() async {
|
|
setUser(const AuthUser(uid: 'anon_999', isAnonymous: true));
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {
|
|
setUser(AuthUser(uid: 'user_${email.hashCode}', email: email));
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithGoogle() async {
|
|
setUser(const AuthUser(uid: 'google_123', email: 'google_rider@test.com'));
|
|
}
|
|
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async {
|
|
if (_user == null) return null;
|
|
return 'mock_token_for_${_user!.uid}';
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
_user = null;
|
|
_state = const AuthState.unauthenticated();
|
|
}
|
|
|
|
@override
|
|
void dispose() {}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('MotorSignal Phase 2C — Cloud Sync Hardening & Production Matrix Tests', () {
|
|
late SharedPreferences prefs;
|
|
late AppDatabase db;
|
|
late TestMockAuthService authService;
|
|
late DateTime simulatedTime;
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
prefs = await SharedPreferences.getInstance();
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
authService = TestMockAuthService();
|
|
simulatedTime = DateTime(2026, 8, 31, 12, 0, 0);
|
|
});
|
|
|
|
tearDown(() async {
|
|
await db.close();
|
|
});
|
|
|
|
// 1. Offline ride -> Ride records normally
|
|
test('Matrix 1: Offline ride records locally in SQLite without cloud requirement', () async {
|
|
final startTime = DateTime(2026, 8, 31, 8, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: startTime,
|
|
distanceMiles: const drift.Value(12.5),
|
|
topSpeedMph: const drift.Value(65.0),
|
|
));
|
|
|
|
final fetched = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle();
|
|
expect(fetched.id, equals(rideId));
|
|
expect(fetched.distanceMiles, equals(12.5));
|
|
});
|
|
|
|
// 2. Signed-out ride -> Ride records normally
|
|
test('Matrix 2: Signed-out rider records normally and sync transitions safely to AUTH_REQUIRED', () async {
|
|
authService.setUser(null);
|
|
final outbox = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.authRequired);
|
|
final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake');
|
|
|
|
final item = await manager.enqueueRide(101, autoProcess: false);
|
|
expect(item.status, equals(SyncStatus.queued));
|
|
|
|
await manager.processQueue();
|
|
final itemAfter = outbox.getItem(101, 'fake');
|
|
expect(itemAfter?.status, equals(SyncStatus.authRequired));
|
|
});
|
|
|
|
// 3. Anonymous ride -> Ride records normally
|
|
test('Matrix 3: Anonymous rider records normally', () async {
|
|
await authService.signInAnonymously();
|
|
expect(authService.currentState.isAnonymous, isTrue);
|
|
|
|
final startTime = DateTime(2026, 8, 31, 9, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: startTime,
|
|
distanceMiles: const drift.Value(4.2),
|
|
topSpeedMph: const drift.Value(45.0),
|
|
));
|
|
|
|
final ride = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle();
|
|
expect(ride.id, equals(rideId));
|
|
});
|
|
|
|
// 4. Authenticated ride -> Ride records normally
|
|
test('Matrix 4: Authenticated rider records normally', () async {
|
|
await authService.signInWithEmailAndPassword('pilot@apex.com', 'secret');
|
|
expect(authService.currentState.isAuthenticated, isTrue);
|
|
|
|
final startTime = DateTime(2026, 8, 31, 10, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: startTime,
|
|
distanceMiles: const drift.Value(22.1),
|
|
topSpeedMph: const drift.Value(85.0),
|
|
));
|
|
|
|
expect(rideId, isPositive);
|
|
});
|
|
|
|
// 5. Firebase unavailable -> Local ride unaffected
|
|
test('Matrix 5: Firebase unavailable leaves local ride intact and marks sync waiting', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.offline);
|
|
final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake');
|
|
|
|
await manager.enqueueRide(105, autoProcess: false);
|
|
await manager.processQueue();
|
|
|
|
expect(outbox.getItem(105, 'fake')?.status, equals(SyncStatus.waitingForNetwork));
|
|
});
|
|
|
|
// 6. Network lost during upload -> Retry scheduled
|
|
test('Matrix 6: Network lost during upload transitions to retryWait with exponential backoff', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure);
|
|
final manager = SyncManager(
|
|
outbox,
|
|
initialProviders: [provider],
|
|
defaultProviderId: 'fake',
|
|
nowProvider: () => simulatedTime,
|
|
);
|
|
|
|
await manager.enqueueRide(106, autoProcess: false);
|
|
await manager.processQueue();
|
|
|
|
final item = outbox.getItem(106, 'fake');
|
|
expect(item?.status, equals(SyncStatus.retryWait));
|
|
expect(item?.attemptCount, equals(1));
|
|
expect(item?.nextRetryAt, equals(simulatedTime.add(const Duration(seconds: 5))));
|
|
});
|
|
|
|
// 7. Permanent server failure -> FAILED terminal state
|
|
test('Matrix 7: Permanent unrecoverable failure terminates retries', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.permanentFailure);
|
|
final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake');
|
|
|
|
await manager.enqueueRide(107, autoProcess: false);
|
|
await manager.processQueue();
|
|
|
|
final item = outbox.getItem(107, 'fake');
|
|
expect(item?.status, equals(SyncStatus.failed));
|
|
expect(item?.status.isTerminal, isTrue);
|
|
});
|
|
|
|
// 8. Auth expiration -> AUTH_REQUIRED
|
|
test('Matrix 8: Auth expiration transitions to AUTH_REQUIRED without deleting local data', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.authRequired);
|
|
final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake');
|
|
|
|
await manager.enqueueRide(108, autoProcess: false);
|
|
await manager.processQueue();
|
|
|
|
final item = outbox.getItem(108, 'fake');
|
|
expect(item?.status, equals(SyncStatus.authRequired));
|
|
});
|
|
|
|
// 9. App restart -> Outbox survives with retry metadata
|
|
test('Matrix 9: App restart restores full outbox state and retry counts', () async {
|
|
final outbox1 = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure);
|
|
final manager1 = SyncManager(
|
|
outbox1,
|
|
initialProviders: [provider],
|
|
defaultProviderId: 'fake',
|
|
nowProvider: () => simulatedTime,
|
|
);
|
|
|
|
await manager1.enqueueRide(109, autoProcess: false);
|
|
await manager1.processQueue();
|
|
|
|
// Simulate App Restart with fresh instances over same SharedPreferences
|
|
final outbox2 = SyncOutbox(prefs);
|
|
expect(outbox2.count, equals(1));
|
|
final reloaded = outbox2.getItem(109, 'fake');
|
|
expect(reloaded?.status, equals(SyncStatus.retryWait));
|
|
expect(reloaded?.attemptCount, equals(1));
|
|
expect(reloaded?.nextRetryAt, equals(simulatedTime.add(const Duration(seconds: 5))));
|
|
});
|
|
|
|
// 10. Duplicate enqueue -> One item
|
|
test('Matrix 10: Duplicate enqueue calls result in exactly one outbox entry', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
await outbox.enqueue(110, 'fake');
|
|
await outbox.enqueue(110, 'fake');
|
|
await outbox.enqueue(110, 'fake');
|
|
|
|
expect(outbox.count, equals(1));
|
|
});
|
|
|
|
// 11. Duplicate upload -> Deterministic cloud identifier
|
|
test('Matrix 11: Duplicate upload attempts generate identical deterministic remote ID', () async {
|
|
final startTime = DateTime(2026, 8, 31, 14, 30, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: startTime,
|
|
distanceMiles: const drift.Value(10.0),
|
|
topSpeedMph: const drift.Value(60.0),
|
|
));
|
|
|
|
final dateStr = '20260831_143000';
|
|
final expectedRemoteId = 'ride_${rideId}_$dateStr';
|
|
|
|
expect(expectedRemoteId, equals('ride_${rideId}_20260831_143000'));
|
|
});
|
|
|
|
// 12. Multiple providers -> Independent execution
|
|
test('Matrix 12: Multiple providers operate independently for the same ride', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final p1 = FakeSyncProvider(providerId: 'firebase', behavior: FakeSyncBehavior.success);
|
|
final p2 = FakeSyncProvider(providerId: 'github', behavior: FakeSyncBehavior.temporaryFailure);
|
|
|
|
final manager = SyncManager(outbox, initialProviders: [p1, p2]);
|
|
await manager.enqueueRide(112, targetProviderId: 'firebase', autoProcess: false);
|
|
await manager.enqueueRide(112, targetProviderId: 'github', autoProcess: false);
|
|
|
|
await manager.processQueue();
|
|
|
|
expect(outbox.getItem(112, 'firebase')?.status, equals(SyncStatus.synced));
|
|
expect(outbox.getItem(112, 'github')?.status, equals(SyncStatus.retryWait));
|
|
});
|
|
|
|
// 13. Large bundle -> Background worker compression
|
|
test('Matrix 13: Large telemetry dataset compresses without throwing', () async {
|
|
final startTime = DateTime(2026, 8, 31, 16, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: startTime,
|
|
distanceMiles: const drift.Value(45.0),
|
|
topSpeedMph: const drift.Value(92.0),
|
|
));
|
|
|
|
// Seed 200 samples
|
|
for (int i = 0; i < 200; i++) {
|
|
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: startTime.add(Duration(milliseconds: i * 50)),
|
|
latitude: drift.Value(37.7749 + i * 0.0001),
|
|
longitude: drift.Value(-122.4194 + i * 0.0001),
|
|
speed: const drift.Value(55.0),
|
|
leanAngle: const drift.Value(24.0),
|
|
accelX: const drift.Value(0.12),
|
|
accelY: const drift.Value(0.18),
|
|
accelZ: const drift.Value(0.96),
|
|
));
|
|
}
|
|
|
|
final worker = SyncBundleWorker(db);
|
|
final zipBytes = await worker.generateCompressedBundle(rideId);
|
|
expect(zipBytes, isNotNull);
|
|
expect(zipBytes!.length, isPositive);
|
|
});
|
|
|
|
// 14. Historical backup -> Safe, repeatable batch enqueue
|
|
test('Matrix 14: Historical backup safely enqueues all local rides', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final manager = SyncManager(outbox);
|
|
|
|
// Create 3 local rides
|
|
for (int i = 1; i <= 3; i++) {
|
|
await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: DateTime(2026, 8, i, 10, 0, 0),
|
|
distanceMiles: drift.Value(10.0 * i),
|
|
topSpeedMph: drift.Value(50.0 + i),
|
|
));
|
|
}
|
|
|
|
final allRides = await db.select(db.rides).get();
|
|
for (final r in allRides) {
|
|
await manager.enqueueRide(r.id, autoProcess: false);
|
|
}
|
|
|
|
expect(outbox.count, equals(3));
|
|
expect(outbox.getPendingItems().length, equals(3));
|
|
});
|
|
|
|
// 15. User A vs User B -> Path isolation
|
|
test('Matrix 15: User A and User B cloud paths are strictly isolated by UID', () {
|
|
const userAUid = 'rider_alpha_123';
|
|
const userBUid = 'rider_bravo_456';
|
|
const remoteRideId = 'ride_14_20260831_120000';
|
|
|
|
final userAPath = 'users/$userAUid/rides/$remoteRideId/telemetry.zip';
|
|
final userBPath = 'users/$userBUid/rides/$remoteRideId/telemetry.zip';
|
|
|
|
expect(userAPath, isNot(equals(userBPath)));
|
|
expect(userAPath, contains(userAUid));
|
|
expect(userBPath, contains(userBUid));
|
|
});
|
|
|
|
// 16. Sync Now -> Non-blocking processing
|
|
test('Matrix 16: processQueue executes asynchronously without blocking caller', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(delayMs: 50);
|
|
final manager = SyncManager(outbox, initialProviders: [provider]);
|
|
|
|
await manager.enqueueRide(116, autoProcess: false);
|
|
final future = manager.processQueue();
|
|
|
|
expect(manager.isProcessing, isTrue);
|
|
await future;
|
|
expect(manager.isProcessing, isFalse);
|
|
});
|
|
|
|
// 17. Retry Failed -> Correct items retried
|
|
test('Matrix 17: retryAllPending re-arms failed and waiting items into queued status', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure);
|
|
final manager = SyncManager(outbox, initialProviders: [provider]);
|
|
|
|
await manager.enqueueRide(117, autoProcess: false);
|
|
await manager.processQueue();
|
|
expect(outbox.getItem(117, 'fake')?.status, equals(SyncStatus.retryWait));
|
|
|
|
// Provider recovers
|
|
provider.behavior = FakeSyncBehavior.success;
|
|
await manager.retryAllPending();
|
|
|
|
expect(outbox.getItem(117, 'fake')?.status, equals(SyncStatus.synced));
|
|
});
|
|
|
|
// SyncObserver verification: riding -> finished transitions
|
|
test('SyncObserver: non-finished to finished transition triggers single enqueue', () {
|
|
final outbox = SyncOutbox(prefs);
|
|
final manager = SyncManager(outbox);
|
|
final observer = SyncObserver(manager);
|
|
|
|
final prev = RideSession(
|
|
dbId: 200,
|
|
state: RideState.riding,
|
|
healthStatus: RecordingHealthStatus.recordingActive,
|
|
);
|
|
|
|
final next = prev.copyWith(
|
|
state: RideState.finished,
|
|
healthStatus: RecordingHealthStatus.recordingComplete,
|
|
);
|
|
|
|
observer.onRideSessionChanged(prev, next);
|
|
expect(outbox.count, equals(1));
|
|
|
|
// Repeated notification does not duplicate
|
|
observer.onRideSessionChanged(prev, next);
|
|
expect(outbox.count, equals(1));
|
|
});
|
|
});
|
|
}
|