fix(sync): completed-ride outbox recovery and cloud confirmation reconciliation
This commit is contained in:
parent
493192078f
commit
c6c8c8df29
|
|
@ -41,6 +41,31 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
return user == null || user.isAnonymous;
|
return user == null || user.isAnonymous;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
||||||
|
final user = _authService.currentUser;
|
||||||
|
if (user == null || user.isAnonymous) return false;
|
||||||
|
if (!await isAvailable()) return false;
|
||||||
|
try {
|
||||||
|
final doc = await _firestore!
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.uid)
|
||||||
|
.collection('rides')
|
||||||
|
.doc(remoteRideId)
|
||||||
|
.get();
|
||||||
|
if (!doc.exists) return false;
|
||||||
|
final data = doc.data();
|
||||||
|
if (data == null) return false;
|
||||||
|
final bundle = data['telemetryBundle'];
|
||||||
|
if (bundle is Map && bundle['status'] == 'uploaded') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
final user = _authService.currentUser;
|
final user = _authService.currentUser;
|
||||||
|
|
@ -72,6 +97,10 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
// The cloud identity is generated once and then reused forever. Never
|
// The cloud identity is generated once and then reused forever. Never
|
||||||
// derive the remote identity from the local auto-increment database ID.
|
// derive the remote identity from the local auto-increment database ID.
|
||||||
final remoteRideId = await _db.ensureCloudRideId(item.localRideId);
|
final remoteRideId = await _db.ensureCloudRideId(item.localRideId);
|
||||||
|
if (await isRideConfirmedInCloud(remoteRideId)) {
|
||||||
|
return SyncResult.success(remoteId: remoteRideId);
|
||||||
|
}
|
||||||
|
|
||||||
final appIdentity = AppIdentity.current();
|
final appIdentity = AppIdentity.current();
|
||||||
final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId);
|
final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId);
|
||||||
if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.');
|
if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.');
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ class GitHubSyncProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> requiresAuthentication() async => auth.getToken() == null;
|
Future<bool> requiresAuthentication() async => auth.getToken() == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
final hasAuth = auth.getToken() != null;
|
final hasAuth = auth.getToken() != null;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,41 @@ import 'sync_provider.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../auth/auth_service.dart';
|
import '../auth/auth_service.dart';
|
||||||
|
|
||||||
|
/// Diagnostic summary returned by [SyncManager.reconcileCompletedRides].
|
||||||
|
class CompletedRideReconciliationReport {
|
||||||
|
final int discoveredCompletedRides;
|
||||||
|
final int alreadyTrackedCount;
|
||||||
|
final int recoveredQueuedCount;
|
||||||
|
final int recoveredAuthRequiredCount;
|
||||||
|
final int recoveredAlreadySyncedCount;
|
||||||
|
final int foreignOwnerSkippedCount;
|
||||||
|
final int errorCount;
|
||||||
|
final List<String> errors;
|
||||||
|
|
||||||
|
const CompletedRideReconciliationReport({
|
||||||
|
this.discoveredCompletedRides = 0,
|
||||||
|
this.alreadyTrackedCount = 0,
|
||||||
|
this.recoveredQueuedCount = 0,
|
||||||
|
this.recoveredAuthRequiredCount = 0,
|
||||||
|
this.recoveredAlreadySyncedCount = 0,
|
||||||
|
this.foreignOwnerSkippedCount = 0,
|
||||||
|
this.errorCount = 0,
|
||||||
|
this.errors = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
int get totalRecovered =>
|
||||||
|
recoveredQueuedCount +
|
||||||
|
recoveredAuthRequiredCount +
|
||||||
|
recoveredAlreadySyncedCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, '
|
||||||
|
'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, '
|
||||||
|
'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, '
|
||||||
|
'foreignSkipped: $foreignOwnerSkippedCount, errors: $errorCount)';
|
||||||
|
}
|
||||||
|
|
||||||
/// Central provider-agnostic synchronization coordinator.
|
/// Central provider-agnostic synchronization coordinator.
|
||||||
///
|
///
|
||||||
/// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted
|
/// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted
|
||||||
|
|
@ -16,17 +51,22 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
final Map<String, SyncProvider> _providers = {};
|
final Map<String, SyncProvider> _providers = {};
|
||||||
String _activeProviderId = 'fake';
|
String _activeProviderId = 'fake';
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
|
Future<CompletedRideReconciliationReport>? _activeReconciliation;
|
||||||
|
Future<void>? _currentProcessingFuture;
|
||||||
|
bool _needsAnotherPass = false;
|
||||||
DateTime? Function() _nowProvider;
|
DateTime? Function() _nowProvider;
|
||||||
final AppDatabase? _db;
|
final AppDatabase? _db;
|
||||||
final AuthService? _authService;
|
final AuthService? _authService;
|
||||||
|
|
||||||
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService, bool autoStart = true}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
||||||
if (initialProviders != null) {
|
if (initialProviders != null) {
|
||||||
for (final p in initialProviders) { registerProvider(p); }
|
for (final p in initialProviders) { registerProvider(p); }
|
||||||
}
|
}
|
||||||
if (defaultProviderId != null) _activeProviderId = defaultProviderId;
|
if (defaultProviderId != null) _activeProviderId = defaultProviderId;
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
unawaited(processQueue());
|
if (autoStart) {
|
||||||
|
unawaited(_startupRecoveryAndProcess());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setNowProvider(DateTime? Function() provider) => _nowProvider = provider;
|
void setNowProvider(DateTime? Function() provider) => _nowProvider = provider;
|
||||||
|
|
@ -87,13 +127,166 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
return enqueued;
|
return enqueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _startupRecoveryAndProcess() async {
|
||||||
|
await reconcileCompletedRides();
|
||||||
|
await processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> retryAuthRequired() async {
|
Future<void> retryAuthRequired() async {
|
||||||
|
final user = _authService?.currentUser;
|
||||||
|
final isAuthenticated = user != null && !user.isAnonymous;
|
||||||
|
if (isAuthenticated && _db != null) {
|
||||||
|
await reconcileCompletedRides();
|
||||||
|
}
|
||||||
for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) {
|
for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) {
|
||||||
|
if (isAuthenticated && _db != null && item.providerId == 'firebase') {
|
||||||
|
await _db.claimRideForCloudOwner(item.localRideId, user.uid);
|
||||||
|
}
|
||||||
await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true));
|
await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true));
|
||||||
}
|
}
|
||||||
await processQueue();
|
await processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discovers completed rides in local SQLite that are missing from [SyncOutbox]
|
||||||
|
/// or need cloud confirmation recovery.
|
||||||
|
Future<CompletedRideReconciliationReport> reconcileCompletedRides({
|
||||||
|
String? targetProviderId,
|
||||||
|
}) async {
|
||||||
|
if (_activeReconciliation != null) {
|
||||||
|
return _activeReconciliation!;
|
||||||
|
}
|
||||||
|
final future = _performReconcileCompletedRides(targetProviderId: targetProviderId);
|
||||||
|
_activeReconciliation = future;
|
||||||
|
try {
|
||||||
|
return await future;
|
||||||
|
} finally {
|
||||||
|
_activeReconciliation = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CompletedRideReconciliationReport> _performReconcileCompletedRides({
|
||||||
|
String? targetProviderId,
|
||||||
|
}) async {
|
||||||
|
if (_db == null) {
|
||||||
|
return const CompletedRideReconciliationReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
final providerId = targetProviderId ?? _activeProviderId;
|
||||||
|
final provider = _providers[providerId];
|
||||||
|
final user = _authService?.currentUser;
|
||||||
|
final isAuthenticated = user != null && !user.isAnonymous;
|
||||||
|
|
||||||
|
int discovered = 0;
|
||||||
|
int alreadyTracked = 0;
|
||||||
|
int recoveredQueued = 0;
|
||||||
|
int recoveredAuthRequired = 0;
|
||||||
|
int recoveredAlreadySynced = 0;
|
||||||
|
int foreignOwnerSkipped = 0;
|
||||||
|
int errorCount = 0;
|
||||||
|
final List<String> errors = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final completedRides = await (_db.select(_db.rides)
|
||||||
|
..where((r) => r.endTime.isNotNull()))
|
||||||
|
.get();
|
||||||
|
discovered = completedRides.length;
|
||||||
|
|
||||||
|
for (final ride in completedRides) {
|
||||||
|
try {
|
||||||
|
// Check if already tracked in outbox for this provider
|
||||||
|
final existingItem = _outbox.getItem(ride.id, providerId);
|
||||||
|
if (existingItem != null) {
|
||||||
|
alreadyTracked++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreign account check: never claim or upload another user's ride
|
||||||
|
if (ride.cloudOwnerUid != null &&
|
||||||
|
isAuthenticated &&
|
||||||
|
ride.cloudOwnerUid != user.uid) {
|
||||||
|
foreignOwnerSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ride.cloudOwnerUid != null && !isAuthenticated) {
|
||||||
|
foreignOwnerSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim unclaimed ride if user is authenticated and target is firebase
|
||||||
|
if (ride.cloudOwnerUid == null && isAuthenticated && providerId == 'firebase') {
|
||||||
|
await _db.claimRideForCloudOwner(ride.id, user.uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable cloud identity check
|
||||||
|
final remoteRideId = await _db.ensureCloudRideId(ride.id);
|
||||||
|
|
||||||
|
// Check if already confirmed uploaded in cloud backend
|
||||||
|
final isConfirmed = provider != null &&
|
||||||
|
await provider.isRideConfirmedInCloud(remoteRideId);
|
||||||
|
|
||||||
|
if (isConfirmed) {
|
||||||
|
final syncedItem = SyncItem(
|
||||||
|
localRideId: ride.id,
|
||||||
|
providerId: providerId,
|
||||||
|
status: SyncStatus.synced,
|
||||||
|
remoteId: remoteRideId,
|
||||||
|
createdAt: ride.endTime ?? ride.startTime,
|
||||||
|
uploadedAt: _now(),
|
||||||
|
);
|
||||||
|
await _outbox.update(syncedItem);
|
||||||
|
recoveredAlreadySynced++;
|
||||||
|
} else {
|
||||||
|
final providerRequiresAuth = provider != null &&
|
||||||
|
await provider.requiresAuthentication();
|
||||||
|
|
||||||
|
if (providerRequiresAuth || (!isAuthenticated && providerId == 'firebase')) {
|
||||||
|
final authReqItem = SyncItem(
|
||||||
|
localRideId: ride.id,
|
||||||
|
providerId: providerId,
|
||||||
|
status: SyncStatus.authRequired,
|
||||||
|
createdAt: ride.endTime ?? ride.startTime,
|
||||||
|
lastError: 'Authentication required for provider "$providerId"',
|
||||||
|
);
|
||||||
|
await _outbox.update(authReqItem);
|
||||||
|
recoveredAuthRequired++;
|
||||||
|
} else {
|
||||||
|
final queuedItem = SyncItem(
|
||||||
|
localRideId: ride.id,
|
||||||
|
providerId: providerId,
|
||||||
|
status: SyncStatus.queued,
|
||||||
|
createdAt: ride.endTime ?? ride.startTime,
|
||||||
|
);
|
||||||
|
await _outbox.update(queuedItem);
|
||||||
|
recoveredQueued++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (itemErr) {
|
||||||
|
errorCount++;
|
||||||
|
errors.add('Ride #${ride.id}: $itemErr');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorCount++;
|
||||||
|
errors.add('Reconciliation failed: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
final report = CompletedRideReconciliationReport(
|
||||||
|
discoveredCompletedRides: discovered,
|
||||||
|
alreadyTrackedCount: alreadyTracked,
|
||||||
|
recoveredQueuedCount: recoveredQueued,
|
||||||
|
recoveredAuthRequiredCount: recoveredAuthRequired,
|
||||||
|
recoveredAlreadySyncedCount: recoveredAlreadySynced,
|
||||||
|
foreignOwnerSkippedCount: foreignOwnerSkipped,
|
||||||
|
errorCount: errorCount,
|
||||||
|
errors: errors,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (report.totalRecovered > 0) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
static Duration calculateRetryDelay(int attemptCount) {
|
static Duration calculateRetryDelay(int attemptCount) {
|
||||||
switch (attemptCount) {
|
switch (attemptCount) {
|
||||||
case 1: return const Duration(seconds: 5);
|
case 1: return const Duration(seconds: 5);
|
||||||
|
|
@ -107,11 +300,28 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) unawaited(processQueue());
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
unawaited(_startupRecoveryAndProcess());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> processQueue() async {
|
Future<void> processQueue() async {
|
||||||
if (_isProcessing) return;
|
if (_currentProcessingFuture != null) {
|
||||||
|
_needsAnotherPass = true;
|
||||||
|
return _currentProcessingFuture!;
|
||||||
|
}
|
||||||
|
final future = _performProcessQueue();
|
||||||
|
_currentProcessingFuture = future;
|
||||||
|
try {
|
||||||
|
await future;
|
||||||
|
} finally {
|
||||||
|
_currentProcessingFuture = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performProcessQueue() async {
|
||||||
|
do {
|
||||||
|
_needsAnotherPass = false;
|
||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
|
|
@ -153,6 +363,7 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
} while (_needsAnotherPass);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelSync(int localRideId, String providerId) async {
|
Future<void> cancelSync(int localRideId, String providerId) async {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ abstract class SyncProvider {
|
||||||
/// Performs the upload of the local ride referenced by [item].
|
/// Performs the upload of the local ride referenced by [item].
|
||||||
/// Must return a deterministic [SyncResult].
|
/// Must return a deterministic [SyncResult].
|
||||||
Future<SyncResult> uploadRide(SyncItem item);
|
Future<SyncResult> uploadRide(SyncItem item);
|
||||||
|
|
||||||
|
/// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud.
|
||||||
|
/// Default implementation returns false.
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulated behavior modes for testing the synchronization engine deterministically
|
/// Simulated behavior modes for testing the synchronization engine deterministically
|
||||||
|
|
@ -36,6 +40,7 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
int delayMs;
|
int delayMs;
|
||||||
final List<SyncItem> uploadedItems = [];
|
final List<SyncItem> uploadedItems = [];
|
||||||
final Map<int, int> uploadAttemptsPerRide = {};
|
final Map<int, int> uploadAttemptsPerRide = {};
|
||||||
|
final Set<String> confirmedRemoteRideIds = {};
|
||||||
|
|
||||||
FakeSyncProvider({
|
FakeSyncProvider({
|
||||||
this.providerId = 'fake',
|
this.providerId = 'fake',
|
||||||
|
|
@ -54,6 +59,11 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
return behavior == FakeSyncBehavior.authRequired;
|
return behavior == FakeSyncBehavior.authRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
||||||
|
return confirmedRemoteRideIds.contains(remoteRideId);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
if (delayMs > 0) {
|
if (delayMs > 0) {
|
||||||
|
|
@ -66,8 +76,10 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
switch (behavior) {
|
switch (behavior) {
|
||||||
case FakeSyncBehavior.success:
|
case FakeSyncBehavior.success:
|
||||||
uploadedItems.add(item);
|
uploadedItems.add(item);
|
||||||
|
final remoteId = 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
confirmedRemoteRideIds.add(remoteId);
|
||||||
return SyncResult.success(
|
return SyncResult.success(
|
||||||
remoteId: 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}',
|
remoteId: remoteId,
|
||||||
);
|
);
|
||||||
|
|
||||||
case FakeSyncBehavior.temporaryFailure:
|
case FakeSyncBehavior.temporaryFailure:
|
||||||
|
|
@ -95,5 +107,6 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
void reset() {
|
void reset() {
|
||||||
uploadedItems.clear();
|
uploadedItems.clear();
|
||||||
uploadAttemptsPerRide.clear();
|
uploadAttemptsPerRide.clear();
|
||||||
|
confirmedRemoteRideIds.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
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/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_outbox.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
|
||||||
|
|
||||||
|
class _TestAuthService implements AuthService {
|
||||||
|
AuthUser? _currentUser;
|
||||||
|
AuthState _authState = const AuthState.unauthenticated();
|
||||||
|
|
||||||
|
void setAuthenticatedUser(String uid, String email) {
|
||||||
|
_currentUser = AuthUser(
|
||||||
|
uid: uid,
|
||||||
|
email: email,
|
||||||
|
displayName: 'Test Rider',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
_authState = AuthState.authenticated(_currentUser!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setGuestUser() {
|
||||||
|
_currentUser = const AuthUser(
|
||||||
|
uid: 'guest_uid_123',
|
||||||
|
email: null,
|
||||||
|
displayName: 'Guest Rider',
|
||||||
|
isAnonymous: true,
|
||||||
|
);
|
||||||
|
_authState = AuthState.anonymous(_currentUser!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLoggedOut() {
|
||||||
|
_currentUser = null;
|
||||||
|
_authState = const AuthState.unauthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthUser? get currentUser => _currentUser;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthState get currentState => _authState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<AuthState> get authStateChanges => Stream.value(_authState);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isConfigured => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isEmailVerified => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteCurrentUser() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake_id_token';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reauthenticate({String? password}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reloadCurrentUser() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendEmailVerification() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> sendPasswordResetEmail(String email) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signInAnonymously() async => setGuestUser();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signInWithEmailAndPassword(String email, String password) async =>
|
||||||
|
setAuthenticatedUser('uid_123', email);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signInWithGoogle() async =>
|
||||||
|
setAuthenticatedUser('uid_google', 'google@example.com');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async => setLoggedOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('v1.1 Completed-Ride Sync Outbox Recovery Tests', () {
|
||||||
|
late SharedPreferences prefs;
|
||||||
|
late AppDatabase db;
|
||||||
|
late _TestAuthService authService;
|
||||||
|
late FakeSyncProvider fakeProvider;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
prefs = await SharedPreferences.getInstance();
|
||||||
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
authService = _TestAuthService();
|
||||||
|
fakeProvider = FakeSyncProvider(providerId: 'firebase');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', () async {
|
||||||
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
||||||
|
|
||||||
|
// Simulate a completed ride committed to SQLite during a previous session
|
||||||
|
final rideId = await db.startRide(
|
||||||
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
||||||
|
motorcycleMake: 'Honda',
|
||||||
|
motorcycleModel: 'CBR600RR',
|
||||||
|
);
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
|
||||||
|
// Confirm outbox starts empty (app terminated before enqueue ran)
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
expect(outbox.count, equals(0));
|
||||||
|
|
||||||
|
// Startup initialization of SyncManager (testing step-by-step)
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
autoStart: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reconcile runs
|
||||||
|
final report = await manager.reconcileCompletedRides();
|
||||||
|
|
||||||
|
expect(report.discoveredCompletedRides, equals(1));
|
||||||
|
expect(report.recoveredQueuedCount, equals(1));
|
||||||
|
expect(report.alreadyTrackedCount, equals(0));
|
||||||
|
expect(outbox.count, equals(1));
|
||||||
|
|
||||||
|
final item = outbox.getItem(rideId, 'firebase');
|
||||||
|
expect(item, isNotNull);
|
||||||
|
expect(item?.status, equals(SyncStatus.queued));
|
||||||
|
|
||||||
|
// Verify ride ownership was claimed for authenticated user
|
||||||
|
final ownerUid = await db.getCloudOwnerUid(rideId);
|
||||||
|
expect(ownerUid, equals('rider_alice'));
|
||||||
|
|
||||||
|
// Process queue to complete upload
|
||||||
|
await manager.processQueue();
|
||||||
|
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
|
||||||
|
expect(fakeProvider.uploadedItems.length, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads', () async {
|
||||||
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
||||||
|
|
||||||
|
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 11, 0, 0));
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
autoStart: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First run reconciles and queues the ride
|
||||||
|
final report1 = await manager.reconcileCompletedRides();
|
||||||
|
expect(report1.recoveredQueuedCount, equals(1));
|
||||||
|
expect(outbox.count, equals(1));
|
||||||
|
|
||||||
|
// Process upload
|
||||||
|
await manager.processQueue();
|
||||||
|
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
|
||||||
|
expect(fakeProvider.uploadedItems.length, equals(1));
|
||||||
|
|
||||||
|
// Second run reports ride as already tracked, zero duplicates
|
||||||
|
final report2 = await manager.reconcileCompletedRides();
|
||||||
|
expect(report2.discoveredCompletedRides, equals(1));
|
||||||
|
expect(report2.alreadyTrackedCount, equals(1));
|
||||||
|
expect(report2.recoveredQueuedCount, equals(0));
|
||||||
|
expect(report2.totalRecovered, equals(0));
|
||||||
|
expect(outbox.count, equals(1));
|
||||||
|
|
||||||
|
// Running processQueue again does not re-upload
|
||||||
|
await manager.processQueue();
|
||||||
|
expect(fakeProvider.uploadedItems.length, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', () async {
|
||||||
|
// User is initially a guest and provider requires auth
|
||||||
|
authService.setGuestUser();
|
||||||
|
fakeProvider.behavior = FakeSyncBehavior.authRequired;
|
||||||
|
|
||||||
|
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 12, 0, 0));
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
autoStart: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reconcile as guest -> authRequired
|
||||||
|
final report = await manager.reconcileCompletedRides();
|
||||||
|
expect(report.recoveredAuthRequiredCount, equals(1));
|
||||||
|
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.authRequired));
|
||||||
|
|
||||||
|
// Ownership remains unclaimed
|
||||||
|
expect(await db.getCloudOwnerUid(rideId), isNull);
|
||||||
|
|
||||||
|
// Guest now signs in with Google / Email
|
||||||
|
authService.setAuthenticatedUser('rider_bob', 'bob@example.com');
|
||||||
|
fakeProvider.behavior = FakeSyncBehavior.success;
|
||||||
|
|
||||||
|
// Invoking retryAuthRequired claims the ride and triggers queue upload
|
||||||
|
await manager.retryAuthRequired();
|
||||||
|
|
||||||
|
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
|
||||||
|
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
|
||||||
|
expect(fakeProvider.uploadedItems.length, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle', () async {
|
||||||
|
authService.setAuthenticatedUser('rider_charlie', 'charlie@example.com');
|
||||||
|
|
||||||
|
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 13, 0, 0));
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
final remoteRideId = await db.ensureCloudRideId(rideId);
|
||||||
|
|
||||||
|
// Simulate that cloud backend already has this ride bundle uploaded
|
||||||
|
fakeProvider.confirmedRemoteRideIds.add(remoteRideId);
|
||||||
|
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
);
|
||||||
|
|
||||||
|
final report = await manager.reconcileCompletedRides();
|
||||||
|
expect(report.recoveredAlreadySyncedCount, equals(1));
|
||||||
|
expect(report.recoveredQueuedCount, equals(0));
|
||||||
|
|
||||||
|
final item = outbox.getItem(rideId, 'firebase');
|
||||||
|
expect(item?.status, equals(SyncStatus.synced));
|
||||||
|
expect(item?.remoteId, equals(remoteRideId));
|
||||||
|
|
||||||
|
// Zero upload attempts because it was already confirmed
|
||||||
|
expect(fakeProvider.uploadedItems.isEmpty, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', () async {
|
||||||
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
||||||
|
|
||||||
|
// Create ride explicitly owned by rider_bob
|
||||||
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
||||||
|
cloudRideId: const drift.Value('ride_bob_123'),
|
||||||
|
cloudOwnerUid: const drift.Value('rider_bob'),
|
||||||
|
startTime: DateTime.utc(2026, 10, 12, 14, 0, 0),
|
||||||
|
endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)),
|
||||||
|
));
|
||||||
|
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
);
|
||||||
|
|
||||||
|
final report = await manager.reconcileCompletedRides();
|
||||||
|
expect(report.foreignOwnerSkippedCount, equals(1));
|
||||||
|
expect(report.totalRecovered, equals(0));
|
||||||
|
expect(outbox.count, equals(0));
|
||||||
|
|
||||||
|
// Ownership in db remains unchanged
|
||||||
|
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('6. In-Progress Isolation: Active rides without endTime are never recovered as completed', () async {
|
||||||
|
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
||||||
|
|
||||||
|
// Start ride but do not end it (endTime is null)
|
||||||
|
final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 16, 0, 0));
|
||||||
|
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: authService,
|
||||||
|
);
|
||||||
|
|
||||||
|
final report = await manager.reconcileCompletedRides();
|
||||||
|
expect(report.discoveredCompletedRides, equals(0));
|
||||||
|
expect(report.totalRecovered, equals(0));
|
||||||
|
expect(outbox.getItem(rideId, 'firebase'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:motorcycle_dash/database/database.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_manager.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
|
||||||
|
|
||||||
|
class _FakeAuthService implements AuthService {
|
||||||
|
final AuthUser _user;
|
||||||
|
|
||||||
|
_FakeAuthService([String uid = 'pilot_pixel_8a'])
|
||||||
|
: _user = AuthUser(
|
||||||
|
uid: uid,
|
||||||
|
email: '$uid@example.com',
|
||||||
|
displayName: 'Pixel Pilot',
|
||||||
|
isAnonymous: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthUser? get currentUser => _user;
|
||||||
|
@override
|
||||||
|
AuthState get currentState => AuthState.authenticated(_user);
|
||||||
|
@override
|
||||||
|
Stream<AuthState> get authStateChanges => Stream.value(currentState);
|
||||||
|
@override
|
||||||
|
bool get isConfigured => true;
|
||||||
|
@override
|
||||||
|
bool get isEmailVerified => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
||||||
|
@override
|
||||||
|
Future<void> deleteCurrentUser() async {}
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
@override
|
||||||
|
Future<void> ensureBackendIdentity() async {}
|
||||||
|
@override
|
||||||
|
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake_token';
|
||||||
|
@override
|
||||||
|
Future<void> reauthenticate({String? password}) async {}
|
||||||
|
@override
|
||||||
|
Future<void> reloadCurrentUser() async {}
|
||||||
|
@override
|
||||||
|
Future<void> sendEmailVerification() async {}
|
||||||
|
@override
|
||||||
|
Future<void> sendPasswordResetEmail(String email) async {}
|
||||||
|
@override
|
||||||
|
Future<void> signInAnonymously() async {}
|
||||||
|
@override
|
||||||
|
Future<void> signInWithEmailAndPassword(String email, String password) async {}
|
||||||
|
@override
|
||||||
|
Future<void> signInWithGoogle() async {}
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('Phase 6 — Disposable Backup Verification & Outbox Recovery Gate', () async {
|
||||||
|
final originalFile = File(
|
||||||
|
r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(originalFile.existsSync(), isTrue, reason: 'Original backup file must exist');
|
||||||
|
|
||||||
|
// 1. Verify original SHA-256 before copy
|
||||||
|
final originalBytes = await originalFile.readAsBytes();
|
||||||
|
final originalHash = sha256.convert(originalBytes).toString();
|
||||||
|
const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122';
|
||||||
|
expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256');
|
||||||
|
|
||||||
|
// 2. Create disposable copy in scratch directory
|
||||||
|
final scratchDir = Directory(
|
||||||
|
r'C:\Users\Bill\.gemini\antigravity\brain\eb590221-346f-4525-a5fa-eda41331c6c6\scratch',
|
||||||
|
);
|
||||||
|
if (!scratchDir.existsSync()) {
|
||||||
|
scratchDir.createSync(recursive: true);
|
||||||
|
}
|
||||||
|
final disposableFile = File('${scratchDir.path}\\disposable_motorsignal.sqlite');
|
||||||
|
if (disposableFile.existsSync()) {
|
||||||
|
disposableFile.deleteSync();
|
||||||
|
}
|
||||||
|
await originalFile.copy(disposableFile.path);
|
||||||
|
expect(disposableFile.existsSync(), isTrue);
|
||||||
|
|
||||||
|
// 3. Open disposable copy via Drift / SQLite
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase(disposableFile));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Integrity check
|
||||||
|
final integrityRows = await db.customSelect('PRAGMA integrity_check').get();
|
||||||
|
final integrityResult = integrityRows.first.data['integrity_check'];
|
||||||
|
expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK');
|
||||||
|
|
||||||
|
// Count rides and samples
|
||||||
|
final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get();
|
||||||
|
final totalRides = rideCountRows.first.data['count'] as int;
|
||||||
|
expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides');
|
||||||
|
|
||||||
|
final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get();
|
||||||
|
final completedRides = completedRidesRows.first.data['count'] as int;
|
||||||
|
|
||||||
|
final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get();
|
||||||
|
final totalSamples = samplesRows.first.data['count'] as int;
|
||||||
|
expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples');
|
||||||
|
|
||||||
|
// 4. Test Foreign Account Isolation against real Pixel backup
|
||||||
|
// In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2'
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
|
||||||
|
final fakeProvider = FakeSyncProvider(providerId: 'firebase');
|
||||||
|
final foreignAuth = _FakeAuthService('foreign_pilot_123');
|
||||||
|
|
||||||
|
final foreignSyncManager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: foreignAuth,
|
||||||
|
autoStart: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final foreignReport = await foreignSyncManager.reconcileCompletedRides();
|
||||||
|
expect(foreignReport.errorCount, equals(0));
|
||||||
|
expect(foreignReport.discoveredCompletedRides, equals(completedRides));
|
||||||
|
expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides');
|
||||||
|
expect(foreignReport.totalRecovered, equals(0));
|
||||||
|
expect(outbox.count, equals(0));
|
||||||
|
|
||||||
|
// 5. Test Owner Recovery against disposable backup
|
||||||
|
final ownerAuth = _FakeAuthService('4btn4W3iOLWgfloMBYYK5hWa1hY2');
|
||||||
|
final ownerSyncManager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [fakeProvider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
db: db,
|
||||||
|
authService: ownerAuth,
|
||||||
|
autoStart: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ownerReport = await ownerSyncManager.reconcileCompletedRides();
|
||||||
|
expect(ownerReport.errorCount, equals(0));
|
||||||
|
expect(ownerReport.discoveredCompletedRides, equals(completedRides));
|
||||||
|
expect(ownerReport.recoveredQueuedCount, equals(completedRides));
|
||||||
|
expect(ownerReport.totalRecovered, equals(completedRides));
|
||||||
|
expect(outbox.count, equals(completedRides));
|
||||||
|
|
||||||
|
// 6. Test repeated reconciliation idempotence as owner
|
||||||
|
final report2 = await ownerSyncManager.reconcileCompletedRides();
|
||||||
|
expect(report2.totalRecovered, equals(0));
|
||||||
|
expect(report2.alreadyTrackedCount, equals(completedRides));
|
||||||
|
expect(outbox.count, equals(completedRides));
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify original SHA-256 after operations to prove zero mutation
|
||||||
|
final postBytes = await originalFile.readAsBytes();
|
||||||
|
final postHash = sha256.convert(postBytes).toString();
|
||||||
|
expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -104,6 +104,9 @@ class _IsolatedContractSyncProvider implements SyncProvider {
|
||||||
return user == null || user.isAnonymous;
|
return user == null || user.isAnonymous;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
if (!networkAvailable) {
|
if (!networkAvailable) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ class _CountingProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> requiresAuthentication() async => result == _Result.authRequired;
|
Future<bool> requiresAuthentication() async => result == _Result.authRequired;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
uploadCount++;
|
uploadCount++;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ class _R2ThenFirestoreProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> requiresAuthentication() async => false;
|
Future<bool> requiresAuthentication() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ class _ValidationProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> requiresAuthentication() async => !authenticated;
|
Future<bool> requiresAuthentication() async => !authenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
uploadCount++;
|
uploadCount++;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue