Files
nearle_pos/test/unit/sync_engine_test.dart
Suriya 0a49323858 Drain bills to the back office automatically, over MQTT or HTTP
Turns the orders table into a queue that empties itself. Bills were only
uploaded when a cashier pressed Sync at end of day; a till that was never
pressed held a day's takings indefinitely.

Drain engine (lib/data/sync/sync_engine.dart)
- Triggers on sale committed, network regained, 5-minute poll, head-office
  request, and the manual button.
- Single flight: a busy till firing a trigger per sale would otherwise have
  several passes reading the same pending rows and send every bill twice.
  A trigger arriving mid-drain is queued and replayed, so nothing is dropped.
- Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter
  matters: a store's terminals all fail at the same instant when the line
  drops, and would retry in lockstep without it.
- Halts rather than loops on a failure retrying cannot fix (bad credential,
  refused batch). Pressing Sync clears the halt.

Transports (lib/data/remote/)
- OrderTransport interface; MQTT, HTTP and simulated implementations. The
  repository does not know which is in use.
- MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return
  topic, retained Last Will for terminal-offline detection, downlink for
  catalogue pushes and remote sync requests.
- A broker PUBACK is never treated as acceptance. It means the broker holds
  the bytes, not that the ledger took the sale. Only ids the back office names
  are marked synced; silence leaves a bill pending.
- HTTP carries a stable idempotency key across retries of the same bills.

Retention
- Accepted bills are kept 7 days instead of deleted, so a batch the back
  office later loses can be re-sent in full. Purged after that; archived
  totals stay forever.
- forBusinessDate now reads pending rows only. A retained bill exists in both
  the orders table and day_archive, and summing both would overstate the day.

Fixes found while building this
- SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart
  evaluates the receiver before the awaited argument, so a connectivity drop
  during the wait was silently overwritten by the stale snapshot. Caught by
  the first run of the new engine tests.
- PrinterSettingsController wrote state after four awaits with no mounted
  check, throwing "used after dispose" when Settings was left mid-load. This
  was pre-existing and reached the cashier as a red screen.

Also
- Header pill now reports real sync state: LIVE / n QUEUED / SYNCING /
  SYNC HALTED, with an explanation of where the bills are.
- Settings shows the route, last upload, next retry and retention window.
- docs/sync-contract.md states what the back office must implement, including
  the idempotency requirement that at-least-once delivery makes mandatory.

Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band,
single flight, halting, ACK correlation and partial acceptance, at-least-once
duplicate handling, retention and purge, and no double-counting after a sync.
Suite run six times clean.

Not addressed: bills already synced by an older build went up overstated and
still need server-side reconciliation. Broker credentials have no Settings
editor yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:57:29 +05:30

437 lines
13 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/data/remote/order_transport.dart';
import 'package:nearle_pos/data/sync/sync_engine.dart';
import 'package:nearle_pos/domain/entities/shift_report.dart';
import 'package:nearle_pos/domain/entities/sync_event.dart';
import 'package:nearle_pos/domain/entities/transaction.dart';
import 'package:nearle_pos/domain/repositories/sync_repository.dart';
/// A repository whose every answer the test dictates.
///
/// The engine is a scheduler; what it schedules is irrelevant here. Driving it
/// with a stub is what makes "did it retry, and when" answerable without a
/// database or a network.
class _StubRepository implements SyncRepository {
_StubRepository();
/// Answers handed out in order; the last one repeats once exhausted.
final List<SyncOutcome> scripted = [];
int calls = 0;
int pending = 0;
/// Completed by the test to hold a drain open, so overlapping triggers can
/// be observed.
Completer<void>? gate;
@override
Future<SyncOutcome> syncOrders({
void Function(double progress, String stage)? onProgress,
}) async {
calls++;
if (gate != null) await gate!.future;
if (scripted.isEmpty) return const SyncOutcome(attempted: 0, uploaded: 0);
return scripted[(calls - 1).clamp(0, scripted.length - 1)];
}
@override
Future<int> unsyncedCount() async => pending;
@override
Future<int> purgeExpired() async => 0;
@override
bool get hasCatalogue => true;
@override
DateTime? get lastImportAt => null;
@override
String? get catalogueRevision => null;
@override
List<SyncEvent> get events => const [];
@override
Future<SyncEvent> importCatalogue({
void Function(double progress, String stage)? onProgress,
}) =>
throw UnimplementedError();
@override
Future<List<SaleTransaction>> unsyncedOrders() async => const [];
@override
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200}) async => const [];
@override
Future<ShiftReport> todayReport({
required String terminalId,
required String cashierName,
bool scopeToCashier = false,
}) =>
throw UnimplementedError();
}
/// Captures what the engine asked to be scheduled instead of really waiting.
class _FakeScheduler {
final List<Duration> delays = [];
final List<void Function()> callbacks = [];
Timer schedule(Duration d, void Function() cb) {
delays.add(d);
callbacks.add(cb);
return Timer(Duration.zero, () {})..cancel();
}
/// Runs the most recently scheduled callback — the backoff retry.
void fireLast() => callbacks.last();
}
void main() {
late _StubRepository repo;
late _FakeScheduler scheduler;
SyncEngine build({
Stream<bool>? connectivity,
Stream<DownlinkMessage>? downlink,
Random? random,
}) =>
SyncEngine(
repository: repo,
connectivity: connectivity,
downlink: downlink,
// Fixed seed so the jitter band is assertable rather than flaky.
random: random ?? Random(7),
scheduleTimer: scheduler.schedule,
);
setUp(() {
repo = _StubRepository();
scheduler = _FakeScheduler();
});
group('single flight', () {
test('overlapping triggers do not start a second pass over the same bills',
() async {
// Without this guarantee a busy till firing a trigger per sale would have
// several drains reading the same pending rows at once, and every bill
// would go up two or three times.
repo.gate = Completer<void>();
final engine = build();
engine
..nudge(SyncTrigger.saleCommitted)
..nudge(SyncTrigger.saleCommitted)
..nudge(SyncTrigger.saleCommitted);
await Future<void>.delayed(Duration.zero);
expect(repo.calls, 1, reason: 'three triggers must yield one drain');
repo.gate!.complete();
repo.gate = null;
await Future<void>.delayed(Duration.zero);
await engine.dispose();
});
test('a trigger arriving mid-drain is honoured once that drain finishes',
() async {
// Bills committed during a pass were not in the set it read. Dropping
// the trigger would leave them waiting for the next poll.
repo
..gate = Completer<void>()
..pending = 3;
final engine = build();
engine.nudge(SyncTrigger.startup);
await Future<void>.delayed(Duration.zero);
expect(repo.calls, 1);
engine.nudge(SyncTrigger.saleCommitted);
repo.gate!.complete();
repo.gate = null;
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(repo.calls, 2, reason: 'the queued trigger must be replayed');
await engine.dispose();
});
test('a queued trigger with nothing left owing does not run a second pass',
() async {
// The counterpart to the test above. The drain that just finished emptied
// the queue, so replaying the trigger would send nothing and only churn
// the connection.
repo
..gate = Completer<void>()
..pending = 0;
final engine = build();
engine.nudge(SyncTrigger.startup);
await Future<void>.delayed(Duration.zero);
engine.nudge(SyncTrigger.saleCommitted);
repo.gate!.complete();
repo.gate = null;
await _settle();
expect(repo.calls, 1);
await engine.dispose();
});
});
group('backoff', () {
test('doubles per failure and stops at the ceiling', () {
final engine = SyncEngine(
repository: repo,
baseBackoff: const Duration(seconds: 2),
maxBackoff: const Duration(minutes: 5),
// No jitter, so the shape of the curve is what is being asserted.
random: _ZeroJitter(),
scheduleTimer: scheduler.schedule,
);
// 0.8 is the bottom of the jitter band, which _ZeroJitter pins.
expect(engine.backoffFor(1).inMilliseconds, 1600); // 2s
expect(engine.backoffFor(2).inMilliseconds, 3200); // 4s
expect(engine.backoffFor(3).inMilliseconds, 6400); // 8s
expect(engine.backoffFor(9).inSeconds, 240); // 512s → capped
expect(engine.backoffFor(30).inSeconds, 240); // still capped
});
test('jitter keeps every delay inside ±20% of the nominal wait', () {
// A shop's terminals all fail at the same instant when the line drops.
// Without jitter they would retry in lockstep and keep colliding.
final engine = build(random: Random(1));
final seen = <int>{};
for (var i = 0; i < 200; i++) {
final ms = engine.backoffFor(4).inMilliseconds;
expect(ms, greaterThanOrEqualTo((16000 * 0.8).round()));
expect(ms, lessThanOrEqualTo((16000 * 1.2).round()));
seen.add(ms);
}
expect(seen.length, greaterThan(50), reason: 'delays must actually vary');
});
test('a failed drain schedules a retry and a success clears it', () async {
repo.scripted.addAll([
const SyncOutcome(attempted: 2, uploaded: 0, error: 'line dropped'),
const SyncOutcome(attempted: 2, uploaded: 2),
]);
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(engine.state.consecutiveFailures, 1);
expect(engine.state.nextAttemptAt, isNotNull);
expect(scheduler.delays, hasLength(1));
scheduler.fireLast();
await _settle();
expect(engine.state.consecutiveFailures, 0);
expect(engine.state.lastError, isNull);
expect(engine.state.nextAttemptAt, isNull);
expect(engine.state.lastSuccessAt, isNotNull);
await engine.dispose();
});
});
group('halting', () {
test('a refused batch halts instead of retrying the same bytes forever',
() async {
// A rejection is a decision, not a fault. Re-sending gets the same
// answer, and a loop would bury the one message a person needs to see.
repo.scripted.add(const SyncOutcome(
attempted: 1,
uploaded: 0,
rejected: 1,
error: 'duplicate invoice number',
isRetryable: false,
),);
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(engine.state.isHalted, isTrue);
expect(scheduler.delays, isEmpty, reason: 'no retry may be scheduled');
// Further background triggers are ignored while halted.
engine
..nudge(SyncTrigger.periodic)
..nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.calls, 1);
await engine.dispose();
});
test('pressing sync clears a halt and tries again', () async {
// The button is how a cashier retries once the back office is fixed.
repo.scripted.add(const SyncOutcome(
attempted: 1,
uploaded: 0,
error: 'bad credential',
isRetryable: false,
),);
final engine = build();
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(engine.state.isHalted, isTrue);
repo.scripted
..clear()
..add(const SyncOutcome(attempted: 1, uploaded: 1));
repo.calls = 0;
await engine.syncNow();
expect(repo.calls, 1);
expect(engine.state.isHalted, isFalse);
await engine.dispose();
});
});
group('triggers', () {
test('the network coming back starts a drain; going away does not',
() async {
final connectivity = StreamController<bool>();
final engine = build(connectivity: connectivity.stream);
await engine.start();
final atStart = repo.calls;
connectivity.add(false);
await _settle();
expect(repo.calls, atStart,
reason: 'an attempt certain to fail is not worth making',);
expect(engine.state.online, isFalse);
connectivity.add(true);
await _settle();
expect(repo.calls, atStart + 1);
await connectivity.close();
await engine.dispose();
});
test('background triggers are ignored while offline, manual is not',
() async {
final connectivity = StreamController<bool>();
final engine = build(connectivity: connectivity.stream);
await engine.start();
connectivity.add(false);
await _settle();
final atStart = repo.calls;
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(repo.calls, atStart);
// The cashier may know something the engine does not, and being told why
// it failed beats a button that does nothing.
await engine.syncNow();
expect(repo.calls, atStart + 1);
await connectivity.close();
await engine.dispose();
});
test('head office can pull a shift up over the downlink', () async {
final downlink = StreamController<DownlinkMessage>();
// Bills owed, otherwise a request to sync correctly does nothing.
repo.pending = 2;
final engine = build(downlink: downlink.stream);
await engine.start();
// Let the startup drain finish, so this measures the downlink and not a
// race with it.
await _settle();
final atStart = repo.calls;
downlink.add(const DownlinkMessage(kind: DownlinkKind.syncRequested));
await _settle();
expect(repo.calls, atStart + 1);
await downlink.close();
await engine.dispose();
});
test('an unrecognised downlink message is ignored, not acted on', () async {
final downlink = StreamController<DownlinkMessage>();
repo.pending = 2;
final engine = build(downlink: downlink.stream);
await engine.start();
await _settle();
final atStart = repo.calls;
downlink.add(const DownlinkMessage(kind: DownlinkKind.unknown));
await _settle();
expect(repo.calls, atStart);
await downlink.close();
await engine.dispose();
});
});
test('a repository that throws is treated as a retryable failure, not a crash',
() async {
// Anything escaping the repository is a defect. The engine must still empty
// its queue once the defect is fixed, rather than dying on the first sale.
final engine = SyncEngine(
repository: _ThrowingRepository(),
random: Random(3),
scheduleTimer: scheduler.schedule,
);
engine.nudge(SyncTrigger.saleCommitted);
await _settle();
expect(engine.state.consecutiveFailures, 1);
expect(engine.state.isHalted, isFalse);
expect(scheduler.delays, hasLength(1));
await engine.dispose();
});
}
/// Pins the jitter multiplier at its lower bound so the backoff curve itself
/// can be asserted.
class _ZeroJitter implements Random {
@override
double nextDouble() => 0;
@override
bool nextBool() => false;
@override
int nextInt(int max) => 0;
}
class _ThrowingRepository extends _StubRepository {
@override
Future<SyncOutcome> syncOrders({
void Function(double progress, String stage)? onProgress,
}) async =>
throw StateError('boom');
}
/// Lets queued microtasks run. The engine never really waits, so a handful of
/// turns is enough for everything it schedules to settle.
Future<void> _settle() async {
for (var i = 0; i < 6; i++) {
await Future<void>.delayed(Duration.zero);
}
}