MotorSignal-App/test/cloud_sync_hardening_test.dart

410 lines
15 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 {
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));
});
});
}