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>
277 lines
8.9 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|