import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/core/config/sync_config.dart'; import 'package:nearle_pos/core/utils/formatters.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/local/terminal_identity.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/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'; /// What has to hold when the same build is installed on 100 tills. /// /// Every one of these was broken: the terminal id was the literal `TERM-01` in /// five places, so the whole fleet shared one identity, one set of MQTT topics /// and one invoice series. void main() { late LocalStore store; setUpAll(() { LocalStore.registerSeed( products: SeedData.products, customers: SeedData.customers, ); }); setUp(() async { store = LocalStore.instance; await store.reset(withCatalogue: true); }); group('identity', () { test('a device mints an identity on first run and keeps it', () async { final identityStore = TerminalIdentityStore(store.catalogue); final first = await identityStore.load(); expect(first.deviceId, isNotEmpty); expect(first.code, startsWith('T')); // A second read must not re-mint. If it did, a terminal would change // identity on every restart and orphan the bills it had already written. final second = await identityStore.load(); expect(second.deviceId, first.deviceId); expect(second.code, first.code); }); test('two devices get different codes', () { // Derived from the device UUID rather than a counter, because there is no // shared counter to draw from — each till mints alone and offline. final a = TerminalIdentityStore.codeFor( 'a1b2c3d4-0000-0000-0000-000000000000', ); final b = TerminalIdentityStore.codeFor( '9f8e7d6c-0000-0000-0000-000000000000', ); expect(a, 'TA1B2'); expect(b, 'T9F8E'); expect(a, isNot(b)); }); test('renaming keeps the device id, so history still points at the till', () async { final identityStore = TerminalIdentityStore(store.catalogue); final before = await identityStore.load(); await identityStore.rename(code: 'till7', name: 'Counter 7'); final after = await identityStore.load(); expect(after.code, 'TILL7'); expect(after.name, 'Counter 7'); expect(after.deviceId, before.deviceId, reason: 'the machine identity must survive a re-code',); }); test('the identity is loaded by the store before anything is written', () async { expect(store.isReady, isTrue); expect(store.terminal.deviceId, isNotEmpty); expect(store.terminal.code, startsWith('T')); }); }); group('invoice numbers', () { test('two terminals ringing their first sale do not collide', () { // The counter lives in each till's own database, so without the terminal // code every device in the fleet mints INV-2608-00001 for its first sale. final date = DateTime(2026, 8, 1); final onTillA = Formatters.invoiceNumber(1, date, terminalCode: 'TA1B2'); final onTillB = Formatters.invoiceNumber(1, date, terminalCode: 'T9F8E'); expect(onTillA, 'INV-2608-TA1B2-00001'); expect(onTillA, isNot(onTillB)); }); test('a real sale carries this terminal\'s code', () async { final products = ProductRepositoryImpl(store); final checkout = CheckoutSale( productRepository: products, customerRepository: CustomerRepositoryImpl(store), transactionRepository: TransactionRepositoryImpl(store), ); final milk = (await products.findByBarcode('8901234500011'))!; final result = await checkout( cart: Cart(lines: [CartLine(product: milk, quantity: 1)]), payments: const [ PaymentSplit(method: PaymentMethod.cash, amount: 62, tendered: 100), ], cashierName: 'Divya', terminalId: store.terminal.code, ); expect(result.transaction.invoiceNumber, contains(store.terminal.code)); expect(result.transaction.terminalId, store.terminal.code); }); }); group('topics', () { test('two terminals in one store publish on different topics', () { const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2'); const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E'); expect(a.orderTopic, isNot(b.orderTopic)); expect(a.statusTopic, isNot(b.statusTopic)); // A shared client id would evict the other till from the broker on every // connect, in a loop. expect(a.clientId, isNot(b.clientId)); }); test('the catalogue topic is shared, because a price change is store-wide', () { const a = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2'); const b = SyncConfig(storeId: 'store-01', terminalId: 'T9F8E'); expect(a.catalogueTopic, b.catalogueTopic); }); test('topics translate to NATS subjects', () { // NATS' MQTT gateway maps / to . — this is what a JetStream consumer // binds to. const config = SyncConfig(storeId: 'store-01', terminalId: 'TA1B2'); expect( SyncConfig.asNatsSubject(config.orderTopic), 'nearle.pos.store-01.TA1B2.order', ); expect( SyncConfig.asNatsSubject(config.statusTopic), 'nearle.pos.store-01.TA1B2.status', ); expect( SyncConfig.asNatsSubject(config.healthTopic), 'nearle.pos.store-01.TA1B2.health', ); }); }); group('database pragmas', () { test('foreign keys are enforced', () async { final rows = await AppDatabase.instance.db.rawQuery('PRAGMA foreign_keys'); expect(rows.first.values.first, 1, reason: 'order_items must cascade with their order',); }); test('a contended lock waits instead of throwing', () async { // Default is 0, which surfaces at checkout as "database is locked" — // a failed sale with a customer standing there. final rows = await AppDatabase.instance.db.rawQuery('PRAGMA busy_timeout'); expect(rows.first.values.first, greaterThanOrEqualTo(5000)); }); }); }