MotorSignal-App/test/sync_test.dart

532 lines
19 KiB
Dart

import 'dart:io';
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/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';
import 'package:motorcycle_dash/services/sync/sync_observer.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MotorSignal Phase 2A — Local Sync Foundation & Outbox Tests', () {
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
// 1. Enqueue one ride
test('1. Outbox: Enqueue one ride', () async {
final outbox = SyncOutbox(prefs);
final item = await outbox.enqueue(101, 'fake');
expect(item.localRideId, equals(101));
expect(item.providerId, equals('fake'));
expect(item.status, equals(SyncStatus.queued));
expect(outbox.count, equals(1));
expect(outbox.getItem(101, 'fake'), isNotNull);
});
// 2. Enqueue duplicate ride
test('2. Outbox: Enqueue duplicate ride prevents duplicate entry', () async {
final outbox = SyncOutbox(prefs);
final item1 = await outbox.enqueue(101, 'fake');
final item2 = await outbox.enqueue(101, 'fake');
expect(outbox.count, equals(1));
expect(item1.key, equals(item2.key));
});
// 3. Enqueue multiple rides
test('3. Outbox: Enqueue multiple distinct rides', () async {
final outbox = SyncOutbox(prefs);
await outbox.enqueue(101, 'fake');
await outbox.enqueue(102, 'fake');
await outbox.enqueue(103, 'fake');
expect(outbox.count, equals(3));
final pending = outbox.getPendingItems();
expect(pending.length, equals(3));
});
// 4. Persistence across reload
test('4. Outbox: Persistence across reload', () async {
final outbox1 = SyncOutbox(prefs);
await outbox1.enqueue(201, 'fake');
await outbox1.enqueue(202, 'fake');
// Create new instance from same preferences
final outbox2 = SyncOutbox(prefs);
expect(outbox2.count, equals(2));
expect(outbox2.getItem(201, 'fake')?.localRideId, equals(201));
expect(outbox2.getItem(202, 'fake')?.localRideId, equals(202));
});
// 5. Corruption recovery
test('5. Outbox: Corruption recovery from invalid manifest data', () async {
await prefs.setString('motorsignal_sync_outbox_manifest_v1', '{corrupt json invalid!!}');
final outbox = SyncOutbox(prefs);
expect(outbox.isLoaded, isTrue);
expect(outbox.count, equals(0)); // Gracefully resets without throwing
});
// 6. Removal after success or cancellation
test('6. Outbox: Removal of item', () async {
final outbox = SyncOutbox(prefs);
await outbox.enqueue(301, 'fake');
expect(outbox.count, equals(1));
await outbox.remove(301, 'fake');
expect(outbox.count, equals(0));
expect(outbox.getItem(301, 'fake'), isNull);
});
});
group('MotorSignal Phase 2A — Sync State Transitions & Provider Dispatch Tests', () {
late SharedPreferences prefs;
late DateTime simulatedTime;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
simulatedTime = DateTime(2026, 8, 31, 8, 0, 0);
});
// 7. Queued state
test('7. State Transitions: Queued state', () async {
final outbox = SyncOutbox(prefs);
final item = await outbox.enqueue(401, 'fake', now: simulatedTime);
expect(item.status, equals(SyncStatus.queued));
expect(item.status.isPending, isTrue);
});
// 8. Waiting for network
test('8. State Transitions: Waiting for network when provider unavailable', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.offline);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager.enqueueRide(402, autoProcess: false);
await manager.processQueue();
final item = outbox.getItem(402, 'fake');
expect(item?.status, equals(SyncStatus.waitingForNetwork));
});
// 9. Uploading state
test('9. State Transitions: Uploading state', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(delayMs: 100);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager.enqueueRide(403, autoProcess: false);
final processFuture = manager.processQueue();
expect(manager.isProcessing, isTrue);
await processFuture;
expect(manager.isProcessing, isFalse);
});
// 10. Synced state upon success
test('10. State Transitions: Synced state upon success', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.success);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager.enqueueRide(404, autoProcess: false);
await manager.processQueue();
final item = outbox.getItem(404, 'fake');
expect(item?.status, equals(SyncStatus.synced));
expect(item?.remoteId, isNotNull);
expect(item?.uploadedAt, isNotNull);
expect(outbox.isRideSynced(404, 'fake'), isTrue);
});
// 11 & 12. Temporary failure and retry wait
test('11-12. State Transitions: Temporary failure enters retry wait with backoff', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager.enqueueRide(405, autoProcess: false);
await manager.processQueue();
final item = outbox.getItem(405, 'fake');
expect(item?.status, equals(SyncStatus.retryWait));
expect(item?.attemptCount, equals(1));
expect(item?.nextRetryAt, equals(simulatedTime.add(const Duration(seconds: 5))));
expect(item?.lastError, contains('HTTP 503'));
});
// 13. Authentication required
test('13. State Transitions: Authentication required leaves item in authRequired', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.authRequired);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager.enqueueRide(406, autoProcess: false);
await manager.processQueue();
final item = outbox.getItem(406, 'fake');
expect(item?.status, equals(SyncStatus.authRequired));
});
// 14. Permanent failure
test('14. State Transitions: Permanent failure terminates retries', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.permanentFailure);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager.enqueueRide(407, autoProcess: false);
await manager.processQueue();
final item = outbox.getItem(407, 'fake');
expect(item?.status, equals(SyncStatus.failed));
expect(item?.status.isTerminal, isTrue);
});
// 15. Cancelled state
test('15. State Transitions: Cancelled state', () async {
final outbox = SyncOutbox(prefs);
final manager = SyncManager(outbox, nowProvider: () => simulatedTime);
await manager.enqueueRide(408, autoProcess: false);
await manager.cancelSync(408, 'fake');
final item = outbox.getItem(408, 'fake');
expect(item?.status, equals(SyncStatus.cancelled));
expect(item?.status.isTerminal, isTrue);
});
});
group('MotorSignal Phase 2A — Retry & Exponential Backoff Tests', () {
late SharedPreferences prefs;
late DateTime simulatedTime;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
simulatedTime = DateTime(2026, 8, 31, 10, 0, 0);
});
// 16. Exponential backoff step calculation
test('16. Retry: Exponential backoff calculation steps', () {
expect(SyncManager.calculateRetryDelay(1), equals(const Duration(seconds: 5)));
expect(SyncManager.calculateRetryDelay(2), equals(const Duration(seconds: 30)));
expect(SyncManager.calculateRetryDelay(3), equals(const Duration(minutes: 2)));
expect(SyncManager.calculateRetryDelay(4), equals(const Duration(minutes: 5)));
expect(SyncManager.calculateRetryDelay(5), equals(const Duration(minutes: 15)));
expect(SyncManager.calculateRetryDelay(6), equals(const Duration(minutes: 30)));
});
// 17. Maximum delay capped at 30 minutes
test('17. Retry: Maximum delay is capped at 30 minutes for attempt 7+', () {
expect(SyncManager.calculateRetryDelay(7), equals(const Duration(minutes: 30)));
expect(SyncManager.calculateRetryDelay(100), equals(const Duration(minutes: 30)));
});
// 18 & 19. Retry count persistence and restart preservation
test('18-19. Retry: Attempt count and nextRetryAt persist across restart', () async {
final outbox1 = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure);
final manager1 = SyncManager(
outbox1,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager1.enqueueRide(501, autoProcess: false);
await manager1.processQueue(); // Attempt 1 -> 5s backoff
simulatedTime = simulatedTime.add(const Duration(seconds: 6));
await manager1.processQueue(); // Attempt 2 -> 30s backoff
final itemBefore = outbox1.getItem(501, 'fake');
expect(itemBefore?.attemptCount, equals(2));
// Simulate App Restart
final outbox2 = SyncOutbox(prefs);
final itemAfter = outbox2.getItem(501, 'fake');
expect(itemAfter?.attemptCount, equals(2));
expect(itemAfter?.status, equals(SyncStatus.retryWait));
expect(itemAfter?.nextRetryAt, isNotNull);
});
// 20. Failed upload eventually succeeds on subsequent retry
test('20. Retry: Item in retryWait succeeds once provider becomes healthy', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
await manager.enqueueRide(502, autoProcess: false);
await manager.processQueue();
expect(outbox.getItem(502, 'fake')?.status, equals(SyncStatus.retryWait));
// Provider recovers
provider.behavior = FakeSyncBehavior.success;
// Fast forward past retry delay
simulatedTime = simulatedTime.add(const Duration(seconds: 10));
await manager.processQueue();
expect(outbox.getItem(502, 'fake')?.status, equals(SyncStatus.synced));
});
});
group('MotorSignal Phase 2A — Fake Provider & Manager Dispatch Tests', () {
late SharedPreferences prefs;
late DateTime simulatedTime;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
simulatedTime = DateTime(2026, 8, 31, 12, 0, 0);
});
// 21. Successful upload
test('21. Fake Provider: Successful upload records item', () async {
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.success);
final item = SyncItem(
localRideId: 601,
providerId: 'fake',
createdAt: simulatedTime,
);
final res = await provider.uploadRide(item);
expect(res.success, isTrue);
expect(provider.uploadedItems.length, equals(1));
});
// 22-25. Fake Provider behavior simulations
test('22-25. Fake Provider: Error and auth behaviors', () async {
final provider = FakeSyncProvider();
final item = SyncItem(localRideId: 602, providerId: 'fake', createdAt: simulatedTime);
provider.behavior = FakeSyncBehavior.temporaryFailure;
expect((await provider.uploadRide(item)).isRetryable, isTrue);
provider.behavior = FakeSyncBehavior.permanentFailure;
expect((await provider.uploadRide(item)).isRetryable, isFalse);
provider.behavior = FakeSyncBehavior.authRequired;
expect((await provider.uploadRide(item)).requiresAuth, isTrue);
});
// 26-30. SyncManager queue processing, skipping ineligible items, and multi-ride
test('26-30. SyncManager: Processes multiple rides and respects retry delays', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(behavior: FakeSyncBehavior.success);
final manager = SyncManager(
outbox,
initialProviders: [provider],
nowProvider: () => simulatedTime,
);
// Enqueue 3 rides
await manager.enqueueRide(701, autoProcess: false);
await manager.enqueueRide(702, autoProcess: false);
await manager.enqueueRide(703, autoProcess: false);
expect(manager.pendingItems.length, equals(3));
await manager.processQueue();
expect(manager.pendingItems.length, equals(0));
expect(outbox.isRideSynced(701, 'fake'), isTrue);
expect(outbox.isRideSynced(702, 'fake'), isTrue);
expect(outbox.isRideSynced(703, 'fake'), isTrue);
final state = manager.currentState;
expect(state.syncedCount, equals(3));
expect(state.pendingCount, equals(0));
expect(state.lastSuccessfulSync, isNotNull);
});
});
group('MotorSignal Phase 2A — External SyncObserver & Subsystem Isolation Tests', () {
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
// 31-33. External observer triggers on finishRide without modifying RideStateMachine or Schema 5
test('31-33. Subsystem Isolation: External SyncObserver enqueues completed rides safely', () async {
final outbox = SyncOutbox(prefs);
final manager = SyncManager(outbox);
final observer = SyncObserver(manager);
// Verify Drift Schema 5 database is operational and untouched
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
final startTime = DateTime(2026, 8, 31, 14, 0, 0);
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
startTime: startTime,
distanceMiles: const drift.Value(5.2),
topSpeedMph: const drift.Value(52.0),
));
// Simulate RideStateMachine state transition from riding -> finished
final prevSession = RideSession(
dbId: rideId,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingActive,
startTime: startTime,
);
final nextSession = prevSession.copyWith(
state: RideState.finished,
healthStatus: RecordingHealthStatus.recordingComplete,
);
// External observer receives state notification
observer.onRideSessionChanged(prevSession, nextSession);
// Confirm item was enqueued to outbox
expect(outbox.count, equals(1));
expect(outbox.getItem(rideId, 'fake'), isNotNull);
expect(outbox.getItem(rideId, 'fake')?.status, equals(SyncStatus.queued));
// Verify no duplicate if observer is called again with same finished session
observer.onRideSessionChanged(prevSession, nextSession);
expect(outbox.count, equals(1));
});
test('Stage 2B: production startup instantiates the ride sync observer', () {
final mainSource = File('lib/main.dart').readAsStringSync();
expect(mainSource, contains("import 'services/sync/sync_observer.dart';"));
expect(mainSource, contains('ref.read(syncObserverProvider);'));
});
test('Stage 2B: completed ride transition enters durable firebase outbox', () async {
final outbox = SyncOutbox(prefs);
final provider = FakeSyncProvider(
providerId: 'firebase',
behavior: FakeSyncBehavior.authRequired,
);
final manager = SyncManager(
outbox,
initialProviders: [provider],
defaultProviderId: 'firebase',
);
final observer = SyncObserver(manager);
addTearDown(manager.dispose);
final previous = RideSession(
dbId: 901,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingActive,
);
final completed = previous.copyWith(
state: RideState.finished,
healthStatus: RecordingHealthStatus.recordingComplete,
);
observer.onRideSessionChanged(previous, completed);
await Future<void>.delayed(Duration.zero);
final item = outbox.getItem(901, 'firebase');
expect(item, isNotNull);
expect(item?.localRideId, 901);
expect(item?.providerId, 'firebase');
expect(
prefs.getString(outbox.storageKey),
contains('"localRideId":901'),
);
});
test('Stage 2B: automatic completion does not invoke legacy queue path', () {
final rideStateMachineSource =
File('lib/features/rides/ride_state_machine.dart').readAsStringSync();
final legacyServiceSource =
File('lib/services/github/github_sync_service.dart').readAsStringSync();
expect(rideStateMachineSource, isNot(contains('queueRideForUpload')));
expect(
rideStateMachineSource,
isNot(contains('services/github/github_sync_service.dart')),
);
expect(legacyServiceSource, contains('queueRideForUpload'));
expect(legacyServiceSource, contains('uploadRideToGitHub'));
});
test('Stage 2B: duplicate completed emissions keep one active outbox item', () async {
final outbox = SyncOutbox(prefs);
final manager = SyncManager(
outbox,
initialProviders: [
FakeSyncProvider(
providerId: 'firebase',
behavior: FakeSyncBehavior.authRequired,
),
],
defaultProviderId: 'firebase',
);
final observer = SyncObserver(manager);
addTearDown(manager.dispose);
final riding = RideSession(
dbId: 902,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingActive,
);
final finished = riding.copyWith(
state: RideState.finished,
healthStatus: RecordingHealthStatus.recordingComplete,
);
observer.onRideSessionChanged(riding, finished);
observer.onRideSessionChanged(riding, finished);
observer.onRideSessionChanged(finished, finished);
await Future<void>.delayed(Duration.zero);
expect(outbox.count, 1);
expect(
outbox.getAllItems().where((item) => item.localRideId == 902),
hasLength(1),
);
expect(outbox.getItem(902, 'firebase'), isNotNull);
});
});
}