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/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/customer.dart'; import 'package:nearle_pos/domain/entities/shift_report.dart'; import 'package:nearle_pos/domain/entities/sync_event.dart'; import 'package:nearle_pos/domain/entities/transaction.dart'; import 'package:nearle_pos/domain/usecases/checkout_sale.dart'; /// Guards the boundary between a live bill and a stored one. /// /// Every figure a shop is paid on survives a write to SQLite and a read back /// out of it. These are regressions: the read path used to rebuild a bill from /// its lines alone, silently dropping bill-level discounts and loyalty. 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, ); }); /// A Gold member (5% tier discount) holding 400 points. Future goldMember() async { final created = await customers.create(const Customer( id: 'ignored', name: 'Gold Shopper', mobile: '9000000001', ),); return customers.update( created.copyWith(loyaltyPoints: 400, lifetimeSpend: 60000), ); } group('order round trip', () { test('bill discount, loyalty and total survive a write and read back', () async { final milk = (await products.findByBarcode('8901234500011'))!; final member = await goldMember(); // 10 x 62.00 = 620 subtotal, less 5% tier (31) and a flat 50 = 81, // less 40 points at 0.25 = 10. Payable 529. final cart = Cart( lines: [CartLine(product: milk, quantity: 10)], customer: member, billDiscount: const Discount(type: DiscountType.flat, value: 50), pointsRedeemed: 40, ); expect(cart.grandTotal, 529); expect(cart.billDiscountTotal, 81); final result = await checkout( cart: cart, payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), ], cashierName: 'Divya', ); final stored = (await transactions.history()).single; expect(stored.total, result.transaction.total, reason: 'the figure charged must not move in storage',); expect(stored.total, 529); expect(stored.cart.billDiscountTotal, 81); expect(stored.cart.loyaltyRedemptionValue, 10); expect(stored.cart.pointsRedeemed, 40); expect(stored.cart.taxAmount, cart.taxAmount); expect(stored.cart.subtotal, 620); }); test('a rebuilt bill does not re-apply the tier discount', () async { final milk = (await products.findByBarcode('8901234500011'))!; final member = await goldMember(); final cart = Cart( lines: [CartLine(product: milk, quantity: 10)], customer: member, ); // 620 less the 5% tier discount only. expect(cart.grandTotal, 589); await checkout( cart: cart, payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 589, tendered: 600), ], cashierName: 'Divya', ); final stored = (await transactions.history()).single; expect(stored.cart.billDiscountTotal, 31); expect(stored.total, 589); }); test('the shift report reads back the amount actually charged', () async { final milk = (await products.findByBarcode('8901234500011'))!; final member = await goldMember(); await checkout( cart: Cart( lines: [CartLine(product: milk, quantity: 10)], customer: member, billDiscount: const Discount(type: DiscountType.flat, value: 50), pointsRedeemed: 40, ), payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), ], cashierName: 'Divya', ); final report = ShiftReport.fromTransactions( transactions: await transactions.history(), businessDate: DateTime.now(), terminalId: 'TERM-01', cashierName: 'Divya', ); expect(report.grossSales, 529); expect(report.discountGiven, 81); expect(report.loyaltyPointsRedeemed, 40); }); test('the bill is stamped with the operator who rang it', () async { final milk = (await products.findByBarcode('8901234500011'))!; await checkout( cart: Cart(lines: [CartLine(product: milk, quantity: 1)]), payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100), ], cashierName: 'Rahul', terminalId: 'TERM-07', ); final stored = (await transactions.history()).single; expect(stored.cashierName, 'Rahul'); expect(stored.terminalId, 'TERM-07'); }); }); group('stock safety', () { test('a resumed parked bill is re-checked against live stock', () async { final milk = (await products.findByBarcode('8901234500011'))!; await transactions.park(ParkedBill( id: 'p-1', cart: Cart(lines: [CartLine(product: milk, quantity: milk.stock)]), parkedAt: DateTime.now(), ),); // The same stock is sold on another bill in the meantime. await products.decrementStock({milk.id: milk.stock}); final resumed = (await transactions.parkedBills()).single; await expectLater( checkout( cart: resumed.cart, payments: [ PaymentSplit( method: PaymentMethod.cash, amount: resumed.cart.grandTotal, tendered: resumed.cart.grandTotal, ), ], cashierName: 'Divya', ), throwsA(isA()), ); expect(await transactions.history(), isEmpty); }); test('a re-import does not restore stock already sold', () async { final milk = (await products.findByBarcode('8901234500011'))!; final opening = milk.stock; await checkout( cart: Cart(lines: [CartLine(product: milk, quantity: 4)]), payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 248, tendered: 250), ], cashierName: 'Divya', ); expect((await products.findByBarcode('8901234500011'))!.stock, opening - 4,); // The morning catalogue is pulled again before the bills went up. await store.importCatalogue( products: SeedData.products(), customers: SeedData.customers(), revision: 'seed-2', at: DateTime.now(), ); expect( (await products.findByBarcode('8901234500011'))!.stock, opening - 4, reason: 'units sold on unsynced bills must not reappear on the shelf', ); }); }); group('atomicity', () { test('nothing is persisted when the sale cannot be completed', () async { final milk = (await products.findByBarcode('8901234500011'))!; final opening = milk.stock; // A customer that was never written to the database. const ghost = Customer(id: 'missing', name: 'Ghost', mobile: '9999999999'); await expectLater( checkout( cart: Cart( lines: [CartLine(product: milk, quantity: 2)], customer: ghost, ), payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 124, tendered: 124), ], cashierName: 'Divya', ), throwsA(isA()), ); expect(await transactions.history(), isEmpty, reason: 'a failed sale must not leave a bill behind',); expect((await products.findByBarcode('8901234500011'))!.stock, opening, reason: 'a failed sale must not consume stock',); }); test('concurrent checkouts never share an invoice sequence', () async { final numbers = await Future.wait( List.generate(25, (_) => transactions.nextInvoiceSequence()), ); expect(numbers.toSet().length, numbers.length); }); }); group('end of day', () { test('the archived day totals match what was charged', () async { final sync = SyncRepositoryImpl( store, RemoteCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); final milk = (await products.findByBarcode('8901234500011'))!; final member = await goldMember(); await checkout( cart: Cart( lines: [CartLine(product: milk, quantity: 10)], customer: member, billDiscount: const Discount(type: DiscountType.flat, value: 50), pointsRedeemed: 40, ), payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 529, tendered: 529), ], cashierName: 'Divya', ); final outcome = await sync.syncOrders(); expect(outcome.uploaded, 1); expect(await sync.unsyncedCount(), 0); // Synced bills are deleted, so today's figures now come from the archive. // A wrong row here is permanent — there is nothing left to recompute from. final report = await sync.todayReport( terminalId: 'TERM-01', cashierName: 'Divya', ); expect(report.billCount, 1); expect(report.grossSales, 529); expect(report.discountGiven, 81); expect(report.loyaltyPointsRedeemed, 40); expect(report.paymentBreakdown[PaymentMethod.cash], 529); }); }); group('sync log', () { test('survives a restart, because it outlives the bills it describes', () async { final sync = SyncRepositoryImpl( store, RemoteCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); final milk = (await products.findByBarcode('8901234500011'))!; await checkout( cart: Cart(lines: [CartLine(product: milk, quantity: 1)]), payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 62), ], cashierName: 'Divya', ); await sync.syncOrders(); expect(sync.events, isNotEmpty); final summary = sync.events.first.summary; // The cache is dropped and rebuilt from disk, as it is on a cold start. await store.hydrate(); expect(sync.events, isNotEmpty, reason: 'the only record that those bills went up must persist',); expect(sync.events.first.summary, summary); expect(sync.events.first.payload['invoices'], isNotEmpty); }); test('a failed import is recorded with its error', () async { final sync = SyncRepositoryImpl( store, RemoteCatalogueSource(isOffline: () => true), SimulatedOrderTransport(isOffline: () => true), ); await sync.importCatalogue(); await store.hydrate(); expect(sync.events.first.status, SyncStatus.failed); expect(sync.events.first.error, isNotNull); }); }); group('shift report scoping', () { Future sell(String cashier, double qty) async { final milk = (await products.findByBarcode('8901234500011'))!; final due = Cart(lines: [CartLine(product: milk, quantity: qty)]).grandTotal; await checkout( cart: Cart(lines: [CartLine(product: milk, quantity: qty)]), payments: [ PaymentSplit(method: PaymentMethod.cash, amount: due, tendered: due), ], cashierName: cashier, ); } test('a cashier settles their own till, not the terminal', () async { final sync = SyncRepositoryImpl( store, RemoteCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); await sell('Divya', 2); // 124 await sell('Rahul', 3); // 186 final divya = await sync.todayReport( terminalId: 'TERM-01', cashierName: 'Divya', scopeToCashier: true, ); final terminal = await sync.todayReport( terminalId: 'TERM-01', cashierName: 'Divya', ); expect(divya.billCount, 1); expect(divya.grossSales, 124); expect(terminal.billCount, 2); expect(terminal.grossSales, 310); }); test('scoping holds after the bills are uploaded and deleted', () async { final sync = SyncRepositoryImpl( store, RemoteCatalogueSource(isOffline: () => false), SimulatedOrderTransport(isOffline: () => false), ); await sell('Divya', 2); await sell('Rahul', 3); await sync.syncOrders(); expect(await sync.unsyncedCount(), 0); final divya = await sync.todayReport( terminalId: 'TERM-01', cashierName: 'Divya', scopeToCashier: true, ); final terminal = await sync.todayReport( terminalId: 'TERM-01', cashierName: 'Divya', ); expect(divya.grossSales, 124, reason: 'the archive must keep each cashier separable',); expect(divya.billCount, 1); expect(terminal.grossSales, 310); expect(terminal.billCount, 2); }); }); group('customer lookup', () { test('a mobile number matches however the cashier punctuates it', () async { await customers.create(const Customer( id: 'ignored', name: 'Punctuation Test', mobile: '9000000042', ),); for (final query in ['9000000042', '90000 00042', '90-000-00042']) { expect( (await customers.search(query)).map((c) => c.name), contains('Punctuation Test'), reason: 'searching "$query" should find the customer', ); } }); }); group('tax invoice', () { test('the slab breakdown sums to the GST charged', () async { // Two different slabs plus a bill discount, so apportioning is in play. final milk = (await products.findByBarcode('8901234500011'))!; // 5% final all = await products.getAll(); final eighteen = all.firstWhere((p) => p.gstRate == 0.18); final cart = Cart( lines: [ CartLine(product: milk, quantity: 3), CartLine(product: eighteen, quantity: 7), ], billDiscount: const Discount(type: DiscountType.percentage, value: 13), ); final summed = cart.taxBreakdown.values.fold(0.0, (a, b) => a + b); expect(summed, closeTo(cart.taxAmount, 0.0001)); }); }); }