import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart'; import 'package:nearle_pos/data/local/app_database.dart'; import 'package:nearle_pos/data/remote/order_transport.dart'; import 'package:nearle_pos/data/remote/simulated_catalogue_source.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/transaction.dart'; import 'package:nearle_pos/domain/usecases/checkout_sale.dart'; /// A shopper who signs up at the till has to reach the back office in their /// own right. Before the customer outbox they only ever travelled as three /// fields riding along on a bill — so somebody who registered and then bought /// nothing, or whose bill was still queued, existed on one terminal and /// nowhere else. void main() { late LocalStore store; late CustomerRepositoryImpl customers; late CheckoutSale checkout; setUpAll(() { LocalStore.registerSeed( products: SeedData.products, customers: SeedData.customers, ); }); setUp(() async { store = LocalStore.instance; await store.reset(withCatalogue: true); customers = CustomerRepositoryImpl(store); checkout = CheckoutSale( productRepository: ProductRepositoryImpl(store), customerRepository: customers, transactionRepository: TransactionRepositoryImpl(store), ); }); tearDownAll(() => AppDatabase.instance.close()); SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl( store, SimulatedCatalogueSource(isOffline: () => false), transport, ); Future register(String mobile, {String name = 'Meena'}) => customers.create(Customer(id: '', name: name, mobile: mobile)); group('identity', () { test('the same mobile produces the same id on any terminal', () { // The whole point. A hundred tills mint ids without talking to each // other, so the id has to be a function of the shopper, not of chance. expect( Customer.idForMobile('9840012345'), Customer.idForMobile('9840012345'), ); }); test('formatting does not create a second shopper', () { final plain = Customer.idForMobile('9840012345'); expect(Customer.idForMobile('+91 98400 12345'), plain); expect(Customer.idForMobile('98400-12345'), plain); }); test('the country code and the trunk prefix are stripped', () { expect(Customer.normaliseMobile('+91 98400 12345'), '9840012345'); expect(Customer.normaliseMobile('098400 12345'), '9840012345'); expect(Customer.normaliseMobile('9840012345'), '9840012345'); }); test('a number the rule was not written for is left alone', () { // Mangling something unrecognised is worse than storing it verbatim: a // wrongly-trimmed number silently merges two different shoppers. expect(Customer.normaliseMobile('4155550123'), '4155550123'); expect(Customer.normaliseMobile('12345'), '12345'); // Twelve digits that do not start with the Indian country code. expect(Customer.normaliseMobile('442071234567'), '442071234567'); }); test('different shoppers get different ids', () { expect( Customer.idForMobile('9840012345'), isNot(Customer.idForMobile('9840012346')), ); }); test('a registration is keyed on the number, not on chance', () async { final created = await register('+91 98400 12345'); expect(created.id, Customer.idForMobile('9840012345')); expect(created.mobile, '9840012345'); }); }); group('the outbox', () { test('a shopper registered at the till is queued', () async { final before = await store.catalogue.unsyncedCustomerCount(); await register('9840012345'); expect(await store.catalogue.unsyncedCustomerCount(), before + 1); }); test('a shopper who buys nothing still goes up', () async { // The case that used to be lost entirely: no bill, so nothing to ride. final created = await register('9840012345'); final transport = _RecordingTransport(); final outcome = await syncWith(transport).syncCustomers(); expect(outcome.uploaded, greaterThanOrEqualTo(1)); expect(transport.sentIds, contains(created.id)); }); test('shoppers that came from the back office are not posted back', () async { // The seed catalogue arrives as an import. Sending it straight back // would be a round trip telling the server what it just told us. final imported = store.customers.map((c) => c.id).toSet(); expect(imported, isNotEmpty, reason: 'the fixture needs seeded shoppers'); final pending = await store.catalogue.unsyncedCustomers(limit: 500); expect( pending.map((c) => c.id).toSet().intersection(imported), isEmpty, ); }); test('only the ids the back office names are marked sent', () async { final kept = await register('9840012345', name: 'Meena'); final dropped = await register('9840099999', name: 'Ravi'); // Silence about a row is not acceptance of it. final transport = _RecordingTransport( accept: (ids) => ids.where((id) => id == kept.id).toList(), ); await syncWith(transport).syncCustomers(); final stillPending = (await store.catalogue.unsyncedCustomers(limit: 500)) .map((c) => c.id) .toSet(); expect(stillPending, contains(dropped.id)); expect(stillPending, isNot(contains(kept.id))); }); test('a batch nobody accepts stops rather than looping for ever', () async { await register('9840012345'); final transport = _RecordingTransport(accept: (_) => const []); final outcome = await syncWith(transport).syncCustomers(); expect(outcome.isSuccess, isFalse); expect(outcome.isRetryable, isFalse); expect(transport.calls, 1, reason: 'the same page must not be re-read'); }); test('an unreachable back office leaves everyone pending', () async { final created = await register('9840012345'); final outcome = await syncWith(_FailingTransport()).syncCustomers(); expect(outcome.isSuccess, isFalse); expect(outcome.uploaded, 0); expect( (await store.catalogue.unsyncedCustomers(limit: 500)) .map((c) => c.id), contains(created.id), ); }); }); group('a sale does not disturb the outbox', () { /// Rings a bill for [customer] so loyalty movement is written. Future ringSaleFor(Customer customer) async { final product = store.products.first; final cart = Cart( lines: [CartLine(product: product, quantity: 1)], customer: customer, ); await checkout( cart: cart, payments: [ PaymentSplit(method: PaymentMethod.cash, amount: cart.grandTotal), ], cashierName: 'Suriya', terminalId: 'T4A9', ); } test('a sale does not re-queue a shopper already sent', () async { final created = await register('9840012345'); await syncWith(_RecordingTransport()).syncCustomers(); expect(await store.catalogue.unsyncedCustomerCount(), 0); await ringSaleFor(created); // A sale writes the shopper's new points and spend. Done as an upsert // that replaces the row, every column absent from it — sync_status // included — would silently revert to its schema default. expect( await store.catalogue.unsyncedCustomerCount(), 0, reason: 'loyalty movement is not a registration change', ); }); test('a sale still moves the loyalty figures', () async { // Guards the fix above from being "achieved" by not writing at all. final created = await register('9840012345'); await ringSaleFor(created); final after = await store.catalogue.customerById(created.id); expect(after!.visitCount, 1); expect(after.lifetimeSpend, greaterThan(0)); expect(after.lastVisitAt, isNotNull); }); test('a sale does not overwrite a profile', () async { final created = await register('9840012345', name: 'Meena'); await ringSaleFor(created); final after = await store.catalogue.customerById(created.id); expect(after!.name, 'Meena'); expect(after.mobile, '9840012345'); }); }); } /// Accepts what it is told to and remembers what it saw. class _RecordingTransport implements OrderTransport { /// Heartbeats are irrelevant to what these tests assert; recorded only so the /// fake satisfies the interface. @override Future publishHealth(String payload) async { healthBeats.add(payload); } final List healthBeats = []; _RecordingTransport({List Function(List ids)? accept}) : accept = accept ?? ((ids) => ids); final List Function(List ids) accept; final sentIds = []; int calls = 0; @override String get label => 'Recording'; @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 => PushReceipt(accepted: orders.map((o) => o['id']! as String).toList()); @override Future pushCustomers( List> customers, ) async { calls++; final ids = customers.map((c) => c['id']! as String).toList(); sentIds.addAll(ids); return PushReceipt(accepted: accept(ids)); } @override Future dispose() async {} } class _FailingTransport implements OrderTransport { /// Heartbeats are irrelevant to what these tests assert; recorded only so the /// fake satisfies the interface. @override Future publishHealth(String payload) async { healthBeats.add(payload); } final List healthBeats = []; @override String get label => 'Failing'; @override bool get isConnected => false; @override Stream get downlink => const Stream.empty(); @override Stream get connectionState => const Stream.empty(); @override Future connect() async {} @override Future pushOrders(List> orders) async => throw const TransportException('unreachable'); @override Future pushCustomers( List> customers, ) async => throw const TransportException('unreachable'); @override Future dispose() async {} }