186 lines
6.9 KiB
Dart
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));
|
|
});
|
|
});
|
|
}
|