Add offline-first cloud sync hardening contract tests
This commit is contained in:
parent
7dfa294a80
commit
6fd81fc1e6
|
|
@ -0,0 +1,129 @@
|
|||
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});
|
||||
|
||||
static const _Result success = _Result.success;
|
||||
static const _Result authRequired = _Result.authRequired;
|
||||
static const _Result failure = _Result.failure;
|
||||
|
||||
_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<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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue