// RID-1 regression coverage: SyncObserver completed-ride enqueue error handling. // // Before this hardening the observer called SyncManager.enqueueRide() as a // naked Future. Because SyncOutbox.enqueue() throws StateError at capacity and // ArgumentError on invalid input, a failing enqueue surfaced as an unhandled // async exception and silently discarded the ride's durable sync intent. // // flutter_test fails a test on any unhandled async error, so these tests are a // genuine regression: they fail against the pre-fix observer. import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:motorcycle_dash/models/recording_health_status.dart'; import 'package:motorcycle_dash/features/rides/ride_state_machine.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_observer.dart'; import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; /// SyncManager seam that can fail the next N enqueue attempts, then delegate to /// the real implementation. Test-only; no production change was required. class _FailingSyncManager extends SyncManager { _FailingSyncManager(super.outbox); int enqueueAttempts = 0; int failuresRemaining = 0; /// Payload that must never reach diagnostics. static const String secretPayload = 'RID-1-SENSITIVE-TOKEN-do-not-log'; @override Future enqueueRide( int localRideId, { String? targetProviderId, bool autoProcess = true, }) async { enqueueAttempts++; if (failuresRemaining > 0) { failuresRemaining--; throw StateError(secretPayload); } return super.enqueueRide( localRideId, targetProviderId: targetProviderId, autoProcess: autoProcess, ); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late SharedPreferences prefs; late List logLines; late DebugPrintCallback originalDebugPrint; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); logLines = []; originalDebugPrint = debugPrint; debugPrint = (String? message, {int? wrapWidth}) { if (message != null) logLines.add(message); }; }); tearDown(() { debugPrint = originalDebugPrint; }); RideSession riding(int dbId) => RideSession( dbId: dbId, state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive, ); RideSession finishedOf(RideSession previous) => previous.copyWith( state: RideState.finished, healthStatus: RecordingHealthStatus.recordingComplete, ); List failureLines() => logLines.where((l) => l.contains('Enqueue failed')).toList(); group('RID-1: SyncObserver enqueue hardening', () { test('enqueue failure is handled and does not escape as an unhandled ' 'async error', () async { final outbox = SyncOutbox(prefs); final manager = _FailingSyncManager(outbox)..failuresRemaining = 1; addTearDown(manager.dispose); final observer = SyncObserver(manager); final previous = riding(900); observer.onRideSessionChanged(previous, finishedOf(previous)); await pumpEventQueue(times: 20); expect(manager.enqueueAttempts, 1); expect(outbox.count, 0, reason: 'a failed enqueue must not create an outbox item'); expect(failureLines(), hasLength(1), reason: 'failure must be reported, not silently swallowed'); }); test('diagnostics carry only the exception class, never its payload', () async { final outbox = SyncOutbox(prefs); final manager = _FailingSyncManager(outbox)..failuresRemaining = 1; addTearDown(manager.dispose); final observer = SyncObserver(manager); final previous = riding(901); observer.onRideSessionChanged(previous, finishedOf(previous)); await pumpEventQueue(times: 20); expect(failureLines().single, contains('StateError')); expect(logLines.join('\n'), isNot(contains(_FailingSyncManager.secretPayload)), reason: 'no token/secret/data may leak into diagnostics'); }); test('repeat completion notifications keep enqueue and logging bounded ' 'to one', () async { final outbox = SyncOutbox(prefs); final manager = _FailingSyncManager(outbox)..failuresRemaining = 5; addTearDown(manager.dispose); final observer = SyncObserver(manager); final previous = riding(902); final completed = finishedOf(previous); observer.onRideSessionChanged(previous, completed); observer.onRideSessionChanged(previous, completed); observer.onRideSessionChanged(completed, completed); await pumpEventQueue(times: 20); expect(manager.enqueueAttempts, 1, reason: 'no duplicate enqueue behaviour introduced'); expect(failureLines(), hasLength(1), reason: 'bounded error logging'); }); test('a later legitimate completion still enqueues after a failure', () async { final outbox = SyncOutbox(prefs); final manager = _FailingSyncManager(outbox)..failuresRemaining = 1; addTearDown(manager.dispose); final observer = SyncObserver(manager); final failed = riding(903); observer.onRideSessionChanged(failed, finishedOf(failed)); await pumpEventQueue(times: 20); expect(outbox.count, 0); final later = riding(904); observer.onRideSessionChanged(later, finishedOf(later)); await pumpEventQueue(times: 20); expect(manager.enqueueAttempts, 2); expect(outbox.getItem(904, 'fake'), isNotNull, reason: 'the observer must stay functional after a handled failure'); // Durable persistence is what the outbox guarantees. The item is // subsequently marked failed by processQueue() because this unit // registers no 'fake' provider - that is existing SyncManager behaviour // and outside RID-1 scope, so only durable presence is asserted here. expect(prefs.getString(outbox.storageKey), contains('"localRideId":904'), reason: 'the recovered enqueue must reach durable storage'); }); test('successful enqueue remains observable synchronously', () { // Existing contract relied on by cloud_sync_hardening_test.dart, which // asserts outbox.count without awaiting. The RID-1 wrapper must not // delay the callee's synchronous prefix. final outbox = SyncOutbox(prefs); final manager = SyncManager(outbox); addTearDown(manager.dispose); final observer = SyncObserver(manager); final previous = riding(905); observer.onRideSessionChanged(previous, finishedOf(previous)); expect(outbox.count, equals(1)); observer.onRideSessionChanged(previous, finishedOf(previous)); expect(outbox.count, equals(1)); }); }); }