532 lines
19 KiB
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);
|
|
});
|
|
});
|
|
}
|