MotorSignal-App/test/rid1_sync_observer_hardenin...

186 lines
6.9 KiB
Dart

// 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<SyncItem> 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<String> 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<String> 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));
});
});
}