Answers "which of my 100 tills are alive and healthy", and fixes three things that were fine on one device and broken on a hundred. Terminal identity (lib/data/local/terminal_identity.dart) - Every device mints a UUID on first run, stored in its own database, plus a short code (T4A9) derived from it. Renaming keeps the device id, so history keeps pointing at the same physical till. - Replaces the literal 'TERM-01', which was hardcoded in five places. The whole fleet reported as one terminal: shift reports merged, MQTT topics collided, and a second connection with the same client id evicts the first from the broker — so two tills would have knocked each other offline in a loop. Invoice numbers now carry the terminal code - INV-2608-T4A9-00042. The sequence counter lives in each till's own database and starts at 1, so without this every terminal in the fleet minted INV-2608-00001 for its first sale of the month. The order UUID kept the data distinct; the number a customer quotes on a receipt was not. SQLite pragmas - WAL, so the product grid refreshing does not block the sale being written, and the file is never left mid-rewrite by a power cut. - busy_timeout 5s, so a contended lock waits instead of throwing "database is locked" — which at checkout is a failed sale with a customer standing there. - synchronous NORMAL, the right trade under WAL for a till. Fleet presence (lib/data/sync/presence_reporter.dart) - Retained status record on connect and once a minute: device id, code, name, app version, pending bill count, last upload, catalogue revision, sync halt state. Retained so a dashboard connecting at noon gets all 100 terminals immediately rather than a blank board. - The Last Will already said "reachable". A till can be connected and still be holding 200 unsent bills or running last month's prices; only pending_bills and catalogue_revision say so. NATS - The MQTT gateway maps / to . so the existing transport works unchanged. SyncConfig.asNatsSubject() exposes the translation, and the contract doc gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two server-side requirements: a file-backed stream, and the ack published by the consumer after commit rather than by the ingest handler. Tests: 129 -> 140. New coverage for identity minting and stability, per-device invoice uniqueness, topic and client-id separation, NATS subject mapping, and the two pragmas. Suite run three times clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
12 KiB
Dart
358 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nearle_pos/data/datasources/local_store.dart';
|
|
import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart';
|
|
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
|
import 'package:nearle_pos/data/local/app_database.dart';
|
|
import 'package:nearle_pos/data/local/order_dao.dart';
|
|
import 'package:nearle_pos/data/remote/order_transport.dart';
|
|
import 'package:nearle_pos/data/remote/simulated_order_transport.dart';
|
|
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/sync_repository_impl.dart';
|
|
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
|
import 'package:nearle_pos/domain/entities/cart.dart';
|
|
import 'package:nearle_pos/domain/entities/transaction.dart';
|
|
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
|
|
|
/// A transport whose answer each call is dictated by the test.
|
|
class _ScriptedTransport implements OrderTransport {
|
|
_ScriptedTransport(this.answer);
|
|
|
|
/// Given the ids in a batch, returns what the back office says about them.
|
|
PushReceipt Function(List<String> ids) answer;
|
|
|
|
int batches = 0;
|
|
|
|
@override
|
|
String get label => 'Scripted';
|
|
|
|
@override
|
|
bool get isConnected => true;
|
|
|
|
@override
|
|
Stream<DownlinkMessage> get downlink => const Stream.empty();
|
|
|
|
@override
|
|
Stream<bool> get connectionState => const Stream.empty();
|
|
|
|
@override
|
|
Future<void> connect() async {}
|
|
|
|
@override
|
|
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
|
|
batches++;
|
|
return answer(orders.map((o) => o['id']! as String).toList());
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {}
|
|
}
|
|
|
|
/// Bills the back office has taken delivery of stay on the terminal for a
|
|
/// week, so a batch the server later loses can still be re-sent in full.
|
|
///
|
|
/// The risk this buys is double counting: an accepted bill is now in two
|
|
/// places at once — its own row, and the archived day totals. Most of what
|
|
/// follows is about that.
|
|
void main() {
|
|
late LocalStore store;
|
|
late ProductRepositoryImpl products;
|
|
late CustomerRepositoryImpl customers;
|
|
late TransactionRepositoryImpl transactions;
|
|
late CheckoutSale checkout;
|
|
|
|
setUpAll(() {
|
|
LocalStore.registerSeed(
|
|
products: SeedData.products,
|
|
customers: SeedData.customers,
|
|
);
|
|
});
|
|
|
|
setUp(() async {
|
|
store = LocalStore.instance;
|
|
await store.reset(withCatalogue: true);
|
|
|
|
products = ProductRepositoryImpl(store);
|
|
customers = CustomerRepositoryImpl(store);
|
|
transactions = TransactionRepositoryImpl(store);
|
|
checkout = CheckoutSale(
|
|
productRepository: products,
|
|
customerRepository: customers,
|
|
transactionRepository: transactions,
|
|
);
|
|
});
|
|
|
|
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
|
|
store,
|
|
RemoteCatalogueSource(isOffline: () => false),
|
|
transport,
|
|
);
|
|
|
|
/// Rings one bill for [quantity] litres of milk at 62.00 each.
|
|
Future<double> ringSale({
|
|
double quantity = 2,
|
|
String cashier = 'Divya',
|
|
}) async {
|
|
final milk = (await products.findByBarcode('8901234500011'))!;
|
|
final cart = Cart(lines: [CartLine(product: milk, quantity: quantity)]);
|
|
final due = cart.grandTotal;
|
|
|
|
await checkout(
|
|
cart: cart,
|
|
payments: [
|
|
PaymentSplit(
|
|
method: PaymentMethod.cash,
|
|
amount: due,
|
|
tendered: due,
|
|
),
|
|
],
|
|
cashierName: cashier,
|
|
terminalId: 'T0TEST',
|
|
);
|
|
return due;
|
|
}
|
|
|
|
group('accepted bills are kept, not deleted', () {
|
|
test('a synced bill is still on the terminal and still re-sendable',
|
|
() async {
|
|
await ringSale();
|
|
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
|
|
|
expect(await sync.unsyncedCount(), 1);
|
|
await sync.syncOrders();
|
|
expect(await sync.unsyncedCount(), 0);
|
|
|
|
// The row survives, carrying its line items, so the full bill can go up
|
|
// again if the back office loses it.
|
|
final rows = await sync.orderSyncRows();
|
|
expect(rows, hasLength(1));
|
|
expect(rows.single.isSynced, isTrue);
|
|
expect(rows.single.syncedAt, isNotNull);
|
|
|
|
final stored = await store.orders.recent();
|
|
expect(stored, hasLength(1));
|
|
expect(stored.single.cart.lines, isNotEmpty,
|
|
reason: 'a kept bill with no lines could not be re-sent',);
|
|
});
|
|
|
|
test("today's takings are not counted twice while the bill is retained",
|
|
() async {
|
|
// The bug this exists to catch: an accepted bill is in the archive *and*
|
|
// still in the orders table. Summing both would inflate the day.
|
|
final due = await ringSale(quantity: 3);
|
|
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
|
|
|
final before = await sync.todayReport(
|
|
terminalId: 'TERM-01',
|
|
cashierName: 'Divya',
|
|
);
|
|
expect(before.grossSales, closeTo(due, 0.01));
|
|
|
|
await sync.syncOrders();
|
|
|
|
final after = await sync.todayReport(
|
|
terminalId: 'TERM-01',
|
|
cashierName: 'Divya',
|
|
);
|
|
expect(after.grossSales, closeTo(due, 0.01),
|
|
reason: 'syncing must not change what the shop took',);
|
|
expect(after.billCount, 1);
|
|
});
|
|
|
|
test('a second sync does not send an already-accepted bill again',
|
|
() async {
|
|
await ringSale();
|
|
final transport = _ScriptedTransport((ids) => PushReceipt(accepted: ids));
|
|
final sync = syncWith(transport);
|
|
|
|
await sync.syncOrders();
|
|
expect(transport.batches, 1);
|
|
|
|
final outcome = await sync.syncOrders();
|
|
expect(outcome.hadNothingToDo, isTrue);
|
|
expect(transport.batches, 1,
|
|
reason: 'a retained bill must not be re-uploaded',);
|
|
});
|
|
});
|
|
|
|
group('purging', () {
|
|
test('a bill past its window goes, and its archived totals stay', () async {
|
|
final due = await ringSale(quantity: 4);
|
|
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
|
await sync.syncOrders();
|
|
|
|
// Backdate the acceptance past the retention window.
|
|
await AppDatabase.instance.db.rawUpdate(
|
|
'UPDATE orders SET synced_at = ?',
|
|
[
|
|
DateTime.now()
|
|
.subtract(OrderDao.retentionWindow + const Duration(days: 1))
|
|
.millisecondsSinceEpoch,
|
|
],
|
|
);
|
|
|
|
expect(await sync.purgeExpired(), 1);
|
|
expect(await store.orders.recent(), isEmpty);
|
|
|
|
// What the shop was paid is unchanged — only the re-sendable copy went.
|
|
final report = await sync.todayReport(
|
|
terminalId: 'TERM-01',
|
|
cashierName: 'Divya',
|
|
);
|
|
expect(report.grossSales, closeTo(due, 0.01));
|
|
expect(report.billCount, 1);
|
|
});
|
|
|
|
test('a bill inside its window is left alone', () async {
|
|
await ringSale();
|
|
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
|
await sync.syncOrders();
|
|
|
|
expect(await sync.purgeExpired(), 0);
|
|
expect(await store.orders.recent(), hasLength(1));
|
|
});
|
|
|
|
test('an unsynced bill is never purged, however old', () async {
|
|
// The one thing that must never happen: a bill the back office has not
|
|
// taken delivery of being deleted from the only place it exists.
|
|
await ringSale();
|
|
await AppDatabase.instance.db.rawUpdate(
|
|
'UPDATE orders SET created_at = ?, synced_at = ?',
|
|
[0, 0],
|
|
);
|
|
|
|
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
|
expect(await sync.purgeExpired(), 0);
|
|
expect(await sync.unsyncedCount(), 1);
|
|
});
|
|
});
|
|
|
|
group('partial acceptance', () {
|
|
test('a bill the back office stayed silent about stays pending', () async {
|
|
// Silence is not acceptance.
|
|
await ringSale(quantity: 1);
|
|
await ringSale(quantity: 2);
|
|
|
|
final transport = _ScriptedTransport(
|
|
(ids) => PushReceipt(accepted: [ids.first]),
|
|
);
|
|
final sync = syncWith(transport);
|
|
|
|
final outcome = await sync.syncOrders();
|
|
expect(outcome.uploaded, 1);
|
|
expect(outcome.rejected, 1);
|
|
expect(await sync.unsyncedCount(), 1,
|
|
reason: 'the unconfirmed bill must still be owed',);
|
|
});
|
|
|
|
test('a refusal stops the drain instead of looping on the same rows',
|
|
() async {
|
|
await ringSale();
|
|
final transport = _ScriptedTransport(
|
|
(ids) => PushReceipt(
|
|
accepted: const [],
|
|
rejected: {for (final id in ids) id: 'duplicate invoice'},
|
|
),
|
|
);
|
|
final sync = syncWith(transport);
|
|
|
|
final outcome = await sync.syncOrders();
|
|
|
|
expect(outcome.uploaded, 0);
|
|
expect(outcome.isSuccess, isFalse);
|
|
expect(outcome.isRetryable, isFalse,
|
|
reason: 'the same bytes will be refused again',);
|
|
expect(transport.batches, 1,
|
|
reason: 'the refused page must not be fetched and sent forever',);
|
|
expect(outcome.error, contains('duplicate invoice'));
|
|
});
|
|
|
|
test('the refusal reason is recorded against the bill for a person to read',
|
|
() async {
|
|
await ringSale();
|
|
final sync = syncWith(_ScriptedTransport(
|
|
(ids) => PushReceipt(
|
|
accepted: const [],
|
|
rejected: {for (final id in ids) id: 'unknown product code'},
|
|
),
|
|
),);
|
|
|
|
await sync.syncOrders();
|
|
|
|
final row = (await sync.orderSyncRows()).single;
|
|
expect(row.isSynced, isFalse);
|
|
expect(row.error, 'unknown product code');
|
|
expect(row.attempts, 1);
|
|
});
|
|
});
|
|
|
|
group('at-least-once delivery', () {
|
|
test('a batch accepted twice is banked once', () async {
|
|
// MQTT will re-deliver, and a lost ack means the terminal sends again.
|
|
// The second acceptance must not double the archived takings.
|
|
final due = await ringSale(quantity: 5);
|
|
final transport = _ScriptedTransport((ids) => PushReceipt(accepted: ids));
|
|
final sync = syncWith(transport);
|
|
|
|
await sync.syncOrders();
|
|
// A duplicate ack for bills already marked synced — the drain finds
|
|
// nothing pending and does nothing.
|
|
await sync.syncOrders();
|
|
|
|
final report = await sync.todayReport(
|
|
terminalId: 'TERM-01',
|
|
cashierName: 'Divya',
|
|
);
|
|
expect(report.grossSales, closeTo(due, 0.01));
|
|
expect(report.billCount, 1);
|
|
});
|
|
});
|
|
|
|
group('transport failure', () {
|
|
test('an unreachable back office leaves every bill exactly where it was',
|
|
() async {
|
|
final due = await ringSale(quantity: 6);
|
|
final sync = syncWith(SimulatedOrderTransport(isOffline: () => true));
|
|
|
|
final outcome = await sync.syncOrders();
|
|
|
|
expect(outcome.isSuccess, isFalse);
|
|
expect(outcome.uploaded, 0);
|
|
expect(await sync.unsyncedCount(), 1);
|
|
|
|
final report = await sync.todayReport(
|
|
terminalId: 'TERM-01',
|
|
cashierName: 'Divya',
|
|
);
|
|
expect(report.grossSales, closeTo(due, 0.01),
|
|
reason: 'a failed upload must not change the shift total',);
|
|
});
|
|
|
|
test('a batch is bounded so a backlog cannot exceed a broker message',
|
|
() async {
|
|
for (var i = 0; i < 5; i++) {
|
|
await ringSale(quantity: 1);
|
|
}
|
|
|
|
final sizes = <int>[];
|
|
final transport = _ScriptedTransport((ids) {
|
|
sizes.add(ids.length);
|
|
return PushReceipt(accepted: ids);
|
|
});
|
|
|
|
final sync = SyncRepositoryImpl(
|
|
store,
|
|
RemoteCatalogueSource(isOffline: () => false),
|
|
transport,
|
|
batchSize: 2,
|
|
);
|
|
|
|
final outcome = await sync.syncOrders();
|
|
|
|
expect(outcome.uploaded, 5);
|
|
expect(sizes, [2, 2, 1], reason: 'the backlog must drain in pages');
|
|
expect(await sync.unsyncedCount(), 0);
|
|
});
|
|
});
|
|
}
|