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 ids) answer; int batches = 0; @override String get label => 'Scripted'; @override bool get isConnected => true; @override Stream get downlink => const Stream.empty(); @override Stream get connectionState => const Stream.empty(); @override Future connect() async {} @override Future pushOrders(List> orders) async { batches++; return answer(orders.map((o) => o['id']! as String).toList()); } @override Future 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 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 = []; 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); }); }); }