Files
nearle_pos/test/unit/transport_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

277 lines
8.9 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:nearle_pos/core/config/sync_config.dart';
import 'package:nearle_pos/data/remote/http_order_transport.dart';
import 'package:nearle_pos/data/remote/mqtt_order_transport.dart';
import 'package:nearle_pos/data/remote/order_transport.dart';
/// Two bills, enough to tell "all accepted" from "some accepted".
final _orders = [
{'id': 'order-a', 'invoice_number': 'INV-1', 'total': 100.0},
{'id': 'order-b', 'invoice_number': 'INV-2', 'total': 250.0},
];
void main() {
group('MQTT ack correlation', () {
late MqttOrderTransport transport;
const config = SyncConfig(
transport: TransportKind.mqtt,
storeId: 'store-9',
terminalId: 'TERM-04',
brokerHost: 'broker.invalid',
);
setUp(() => transport = MqttOrderTransport(config: config));
tearDown(() => transport.dispose());
test('topics are namespaced per store and per terminal', () {
// Two stores sharing one broker must never see each other's bills.
expect(config.orderTopic, 'pos/store-9/TERM-04/order');
expect(config.ackTopic, 'pos/store-9/TERM-04/ack');
expect(config.statusTopic, 'pos/store-9/TERM-04/status');
expect(config.catalogueTopic, 'pos/store-9/catalogue');
});
test('an ack naming only some ids accepts only those', () async {
// The heart of it. A partial ack must not be read as "the batch went up":
// order-b stays pending and is sent again.
final receipt = _receiptFor(transport, {
'accepted': ['order-a'],
'rejected': {'order-b': 'unknown product'},
});
final result = await receipt;
expect(result.accepted, ['order-a']);
expect(result.rejected, {'order-b': 'unknown product'});
});
test('an ack for a different batch does not release this one', () async {
final pending = transport.pushOrders(_orders).timeout(
const Duration(milliseconds: 300),
onTimeout: () => throw TimeoutException('not released'),
);
// Give the publish a turn to register its correlation id, then answer
// with someone else's.
await Future<void>.delayed(Duration.zero);
transport.handleInbound(
config.ackTopic,
jsonEncode({'batch_id': 'a-different-batch', 'accepted': ['order-a']}),
);
await expectLater(pending, throwsA(isA<Exception>()));
});
test('a malformed ack is discarded rather than taken down the connection',
() {
// The next message may be a perfectly good ack releasing a day's bills.
expect(
() => transport.handleInbound(config.ackTopic, 'not json at all'),
returnsNormally,
);
expect(
() => transport.handleInbound(config.ackTopic, '{"no":"batch id"}'),
returnsNormally,
);
});
test('an ack with no accepted list releases nothing', () async {
// Silence is not acceptance. A back office that answers `{}` must not
// cause a single bill to be marked synced.
final result = await _receiptFor(transport, {'accepted': <String>[]});
expect(result.accepted, isEmpty);
});
test('a bare list of rejected ids is tolerated', () async {
final result = await _receiptFor(transport, {
'accepted': ['order-a'],
'rejected': ['order-b'],
});
expect(result.rejected.keys, ['order-b']);
});
test('catalogue pushes arrive on the downlink', () async {
final received = <DownlinkMessage>[];
final sub = transport.downlink.listen(received.add);
transport
..handleInbound(config.catalogueTopic, jsonEncode({'revision': 'r9'}))
..handleInbound(config.commandTopic, jsonEncode({'command': 'sync'}))
..handleInbound(
config.commandTopic,
jsonEncode({'command': 'self-destruct'}),
);
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(
received.map((m) => m.kind),
[
DownlinkKind.catalogueChanged,
DownlinkKind.syncRequested,
// A newer server talking to an older terminal stays visible instead
// of being silently dropped.
DownlinkKind.unknown,
],
);
expect(received.first.payload['revision'], 'r9');
});
});
group('HTTP transport', () {
const SyncConfig config = SyncConfig(
transport: TransportKind.http,
httpBaseUrl: 'https://back.office.test',
apiKey: 'k',
);
test('accepts only the ids the endpoint names', () async {
final transport = HttpOrderTransport(
config: config,
client: _FakeClient((_) => http.Response(
jsonEncode({
'accepted': ['order-a'],
'rejected': {'order-b': 'stale price list'},
}),
200,
),),
);
final receipt = await transport.pushOrders(_orders);
expect(receipt.accepted, ['order-a']);
expect(receipt.rejected['order-b'], 'stale price list');
await transport.dispose();
});
test('a bare 200 with no body marks nothing synced', () async {
// Guessing here would retire a day's takings on an empty response.
final transport = HttpOrderTransport(
config: config,
client: _FakeClient((_) => http.Response('', 200)),
);
await expectLater(
transport.pushOrders(_orders),
throwsA(isA<TransportException>()),
);
await transport.dispose();
});
test('a 200 that names nothing accepts nothing, without throwing',
() async {
final transport = HttpOrderTransport(
config: config,
client: _FakeClient((_) => http.Response('{"accepted":[]}', 200)),
);
final receipt = await transport.pushOrders(_orders);
expect(receipt.accepted, isEmpty);
await transport.dispose();
});
test('a bad credential is not retryable, so the engine can halt', () async {
// Hammering the endpoint would only bury the one message a person needs.
final transport = HttpOrderTransport(
config: config,
client: _FakeClient((_) => http.Response('nope', 401)),
);
await expectLater(
transport.pushOrders(_orders),
throwsA(isA<TransportException>()
.having((e) => e.retryable, 'retryable', isFalse),),
);
await transport.dispose();
});
test('a server error is retryable', () async {
final transport = HttpOrderTransport(
config: config,
client: _FakeClient((_) => http.Response('boom', 503)),
);
await expectLater(
transport.pushOrders(_orders),
throwsA(isA<TransportException>()
.having((e) => e.retryable, 'retryable', isTrue),),
);
await transport.dispose();
});
test('a retry of the same bills carries the same idempotency key',
() async {
// So the endpoint can collapse a duplicate batch server-side rather than
// relying on every order id being checked one at a time.
final keys = <String>[];
final transport = HttpOrderTransport(
config: config,
client: _FakeClient((request) {
keys.add(request.headers['idempotency-key'] ?? '');
return http.Response('{"accepted":["order-a","order-b"]}', 200);
}),
);
await transport.pushOrders(_orders);
await transport.pushOrders(_orders);
expect(keys.first, keys.last);
expect(keys.first, isNotEmpty);
await transport.pushOrders([_orders.first]);
expect(keys.last, isNot(keys.first));
await transport.dispose();
});
test('an unconfigured endpoint fails fast rather than retrying', () async {
final transport = HttpOrderTransport(
config: const SyncConfig(transport: TransportKind.http),
client: _FakeClient((_) => http.Response('', 200)),
);
await expectLater(
transport.pushOrders(_orders),
throwsA(isA<TransportException>()
.having((e) => e.retryable, 'retryable', isFalse),),
);
await transport.dispose();
});
});
}
/// Waits on a known batch id, then feeds it [body] as if the back office had
/// answered — exercising the real parse and correlation path with no broker.
Future<PushReceipt> _receiptFor(
MqttOrderTransport transport,
Map<String, Object?> body,
) {
const batchId = 'test-batch';
final receipt = transport.awaitAck(batchId);
transport.handleInbound(
transport.config.ackTopic,
jsonEncode({...body, 'batch_id': batchId}),
);
return receipt;
}
class _FakeClient extends http.BaseClient {
_FakeClient(this.respond);
final http.Response Function(http.BaseRequest) respond;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final response = respond(request);
return http.StreamedResponse(
Stream.value(utf8.encode(response.body)),
response.statusCode,
request: request,
);
}
}