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>
This commit is contained in:
276
test/unit/transport_test.dart
Normal file
276
test/unit/transport_test.dart
Normal file
@@ -0,0 +1,276 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user