MotorSignal-App/test/offline_first_sync_contract...

129 lines
5.1 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
import 'package:motorcycle_dash/services/sync/sync_models.dart';
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
class _CountingProvider implements SyncProvider {
_CountingProvider({this.result = _Result.success});
_Result result;
int uploadCount = 0;
@override
String get providerId => 'firebase';
@override
Future<bool> isAvailable() async => true;
@override
Future<bool> requiresAuthentication() async => result == _Result.authRequired;
@override
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
@override
Future<SyncResult> uploadRide(SyncItem item) async {
uploadCount++;
switch (result) {
case _Result.success:
return SyncResult.success(remoteId: 'remote_${item.localRideId}');
case _Result.authRequired:
return SyncResult.authRequired(message: 'Authentication required');
case _Result.failure:
return SyncResult.permanentFailure('Upload rejected');
}
}
}
enum _Result { success, authRequired, failure }
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MotorSignal offline-first sync contract', () {
test('same ride uploaded twice is idempotent', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final outbox = SyncOutbox(prefs, storageNamespace: 'rider_a');
final provider = _CountingProvider();
final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'firebase');
await manager.enqueueRide(42, autoProcess: false);
await manager.processQueue();
expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.synced);
await manager.enqueueRide(42, autoProcess: false);
await manager.processQueue();
expect(provider.uploadCount, 1);
expect(outbox.count, 1);
expect(outbox.getItem(42, 'firebase')?.remoteId, 'remote_42');
});
test('User A and User B have isolated persisted sync state', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final userA = SyncOutbox(prefs, storageNamespace: 'uid_user_a');
await userA.enqueue(7, 'firebase');
await userA.update(userA.getItem(7, 'firebase')!.copyWith(status: SyncStatus.synced));
final userB = SyncOutbox(prefs, storageNamespace: 'uid_user_b');
expect(userB.count, 0);
expect(userB.getItem(7, 'firebase'), isNull);
final userAReloaded = SyncOutbox(prefs, storageNamespace: 'uid_user_a');
expect(userAReloaded.getItem(7, 'firebase')?.status, SyncStatus.synced);
});
test('signed-out upload is rejected before provider network upload', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final outbox = SyncOutbox(prefs, storageNamespace: 'local');
final provider = _CountingProvider(result: _Result.authRequired);
final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'firebase');
await manager.enqueueRide(9, autoProcess: false);
await manager.processQueue();
expect(outbox.getItem(9, 'firebase')?.status, SyncStatus.authRequired);
expect(provider.uploadCount, 0);
});
test('failed upload remains unsynced and retains failure state', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final outbox = SyncOutbox(prefs, storageNamespace: 'uid_user_a');
final provider = _CountingProvider(result: _Result.failure);
final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'firebase');
await manager.enqueueRide(11, autoProcess: false);
await manager.processQueue();
final item = outbox.getItem(11, 'firebase');
expect(item?.status, SyncStatus.failed);
expect(item?.status, isNot(SyncStatus.synced));
expect(item?.lastError, 'Upload rejected');
});
test('successful sync survives app restart', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final firstOutbox = SyncOutbox(prefs, storageNamespace: 'uid_restart');
final provider = _CountingProvider();
final manager = SyncManager(firstOutbox, initialProviders: [provider], defaultProviderId: 'firebase');
await manager.enqueueRide(13, autoProcess: false);
await manager.processQueue();
expect(firstOutbox.getItem(13, 'firebase')?.status, SyncStatus.synced);
// Fresh outbox/manager instances represent a process restart.
final restartedOutbox = SyncOutbox(prefs, storageNamespace: 'uid_restart');
expect(restartedOutbox.getItem(13, 'firebase')?.status, SyncStatus.synced);
expect(restartedOutbox.getItem(13, 'firebase')?.remoteId, 'remote_13');
});
});
}