import 'dart:convert'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/database/app_database.dart'; import 'package:kooltap/models/bar_tab.dart'; import 'package:kooltap/models/payment_method.dart'; import 'package:kooltap/models/product.dart'; import 'package:kooltap/models/tab_event.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/services/product_service.dart'; const _cola = Product( id: 'prod-1', name: 'Cola', category: 'Drinks', stockQuantity: 100, lowStockThreshold: 10, priceInCents: 250, ); void main() { late AppDatabase database; late DriftBarTabService service; setUp(() async { database = AppDatabase(NativeDatabase.memory()); service = DriftBarTabService(database: database); await database .into(database.products) .insert( ProductsCompanion.insert( id: _cola.id, name: _cola.name, category: _cola.category, stockQuantity: _cola.stockQuantity, lowStockThreshold: _cola.lowStockThreshold, priceInCents: _cola.priceInCents, ), ); }); tearDown(() async { await database.close(); }); Future>> outbox() async { final rows = await (database.select(database.tabEvents)..orderBy([ (row) => OrderingTerm.asc(const CustomExpression('rowid')), ])) .get(); return [ for (final row in rows) jsonDecode(row.payload) as Map, ]; } Future lineIdOf(String tabId) async { return (await service.getTabById(tabId))!.items.single.id; } /// Opens a tab and drops its `tab_opened` event. Future openTab(String name) async { final tab = await service.createTab(customerName: name); await database.delete(database.tabEvents).go(); return tab; } Future tabWithTwoColas() async { final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab(tabId: tab.id, product: _cola); await database.delete(database.tabEvents).go(); return (database.select( database.barTabs, )..where((row) => row.id.equals(tab.id))).getSingle(); } group('line events', () { test('adding a product writes line_added with the common fields', () async { final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); final event = (await outbox()).single; expect(event['type'], 'line_added'); expect(event['tabId'], tab.id); expect(event['customerName'], 'Jan'); expect(event['productId'], 'prod-1'); expect(event['lineId'], await lineIdOf(tab.id)); expect(event['productName'], 'Cola'); expect(event['unitPriceInCents'], 250); expect(event['quantity'], 1); expect(event['id'], matches(RegExp(r'^[0-9a-f-]{36}$'))); expect(event['occurredAt'], endsWith('Z')); final row = await database.select(database.tabEvents).getSingle(); expect(row.id, event['id']); }); test( 'a drink at a new price goes on a new line, with that price', () async { final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab( tabId: tab.id, product: _cola.copyWith(priceInCents: 300), ); final events = await outbox(); expect(events.map((event) => event['unitPriceInCents']), [250, 300]); expect(events.map((event) => event['productId']), ['prod-1', 'prod-1']); // A different line, so the server doesn't put it on the old one either expect(events[0]['lineId'], isNot(events[1]['lineId'])); }, ); test( '+ on a line at an old price sends the new price, on a new line', () async { final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); final oldLine = await lineIdOf(tab.id); await (database.update(database.products) ..where((row) => row.id.equals('prod-1'))) .write(const ProductsCompanion(priceInCents: Value(300))); await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1); final added = (await outbox()).last; expect(added['type'], 'line_added'); expect(added['unitPriceInCents'], 300); expect(added['quantity'], 1); expect(added['lineId'], isNot(oldLine)); }, ); test('a line without a product sends no productId', () async { final tab = await openTab('Jan'); // As taken over from the tablet's own tabs: the line has no product await database .into(database.tabItems) .insert( TabItemsCompanion.insert( id: 'seed:${tab.id}:0', tabId: tab.id, productId: '', productName: 'Cola', quantity: 1, unitPriceInCents: 250, createdAt: DateTime.now(), ), ); await service.adjustTabItemQuantity( tabItemId: 'seed:${tab.id}:0', delta: -1, ); final event = (await outbox()).last; expect(event['type'], 'line_removed'); expect(event.containsKey('productId'), isFalse); expect(event['productName'], 'Cola'); }); test('a drink at the same price goes on the same line', () async { final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab(tabId: tab.id, product: _cola); final events = await outbox(); expect(events[0]['lineId'], events[1]['lineId']); }); test('changing a quantity writes the difference', () async { final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab(tabId: tab.id, product: _cola); final lineId = await lineIdOf(tab.id); await service.adjustTabItemQuantity(tabItemId: lineId, delta: 3); await service.adjustTabItemQuantity(tabItemId: lineId, delta: -4); final events = (await outbox()).skip(2).toList(); expect(events[0]['type'], 'line_added'); expect(events[0]['quantity'], 3); expect(events[1]['type'], 'line_removed'); expect(events[1]['quantity'], 4); expect(events[1]['unitPriceInCents'], 250); }); test('removing a line writes line_removed for what was left', () async { final tab = await tabWithTwoColas(); final lineId = await lineIdOf(tab.id); // More than is on the tab: only the 2 that were there are removed await service.adjustTabItemQuantity(tabItemId: lineId, delta: -5); final event = (await outbox()).single; expect(event['type'], 'line_removed'); expect(event['quantity'], 2); }); test('a change that does nothing writes no event', () async { final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); final lineId = await lineIdOf(tab.id); await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1); await database.delete(database.tabEvents).go(); await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1); await service.adjustTabItemQuantity(tabItemId: lineId, delta: 0); expect(await outbox(), isEmpty); }); test( 'deleting a tab writes line_removed for each line, then tab_deleted', () async { final tab = await tabWithTwoColas(); await service.deleteTab(tab.id); final events = await outbox(); expect(events.map((event) => event['type']), [ 'line_removed', 'tab_deleted', ]); expect(events.first['quantity'], 2); expect(events.first['customerName'], 'Jan'); expect(events.last['tabId'], tab.id); }, ); test('later events use the new name after a rename', () async { final tab = await openTab('Jan'); await service.renameTab(tabId: tab.id, customerName: 'Janneke'); await service.addProductToTab(tabId: tab.id, product: _cola); final events = await outbox(); expect(events.map((event) => event['type']), [ 'tab_renamed', 'line_added', ]); expect(events.map((event) => event['customerName']), [ 'Janneke', 'Janneke', ]); }); test('a failed change writes no event', () async { await expectLater( service.addProductToTab(tabId: 'missing', product: _cola), throwsA(isA()), ); expect(await outbox(), isEmpty); }); }); group('tab events', () { test('opening a tab writes tab_opened in the same transaction', () async { final tab = await service.createTab(customerName: 'Jan'); final event = (await outbox()).single; expect(event['type'], 'tab_opened'); expect(event['tabId'], tab.id); expect(event['customerName'], 'Jan'); // The database keeps whole seconds final openedAt = DateTime.parse(event['openedAt'] as String); expect(openedAt.difference(tab.openedAt).inSeconds, 0); }); test('a rename of an unknown tab writes nothing', () async { await expectLater( service.renameTab(tabId: 'missing', customerName: 'X'), throwsA(isA()), ); expect(await outbox(), isEmpty); }); }); group('settled events', () { test('settling writes one settled event and no removals', () async { final tab = await tabWithTwoColas(); await service.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq); final event = (await outbox()).single; expect(event['type'], 'settled'); expect(event['customerName'], 'Jan'); expect(event['amountInCents'], 500); expect(event['paymentMethod'], 'payconiq'); expect(event.containsKey('commandId'), false); expect(event['items'], [ { 'productId': 'prod-1', 'productName': 'Cola', 'quantity': 2, 'unitPriceInCents': 250, }, ]); // History and the server's settlement feed share the event's id final closed = await service.getClosedTabs(); expect(closed.single.id, event['id']); }); test('lines that were taken back are not settled', () async { final tab = await tabWithTwoColas(); final lineId = await lineIdOf(tab.id); await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1); await service.closeTab(tab.id); final settled = (await outbox()).last; expect(settled['amountInCents'], 250); expect(settled['paymentMethod'], 'cash'); expect((settled['items'] as List).single['quantity'], 1); }); test('settling an empty tab writes nothing', () async { final tab = await openTab('Jan'); await service.closeTab(tab.id); expect(await outbox(), isEmpty); }); test('a version conflict writes nothing', () async { final tab = await tabWithTwoColas(); await expectLater( service.closeTab(tab.id, expectedVersion: tab.version - 1), throwsA(isA()), ); expect(await outbox(), isEmpty); }); test('carries the command id when given', () async { final tab = await tabWithTwoColas(); await service.closeTab( tab.id, paymentMethod: PaymentMethod.paymentPending, commandId: 'cmd-1', ); final event = (await outbox()).single; expect(event['commandId'], 'cmd-1'); expect(event['paymentMethod'], 'payment_pending'); }); }); group('stock', () { Future colaStock() async { final row = await (database.select( database.products, )..where((row) => row.id.equals(_cola.id))).getSingle(); return row.stockQuantity; } test('moves with every line event', () async { final tab = await openTab('Jan'); for (var i = 0; i < 3; i++) { await service.addProductToTab(tabId: tab.id, product: _cola); } expect(await colaStock(), 97); await service.adjustTabItemQuantity( tabItemId: await lineIdOf(tab.id), delta: -2, ); expect(await colaStock(), 99); await service.deleteTab(tab.id); expect(await colaStock(), 100); }); test('settling leaves the stock alone', () async { final tab = await tabWithTwoColas(); await service.closeTab(tab.id); expect(await colaStock(), 98); }); test('a product without tracked stock never changes', () async { await (database.update(database.products) ..where((row) => row.id.equals(_cola.id))) .write(const ProductsCompanion(infiniteStock: Value(true))); final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab(tabId: tab.id, product: _cola); await service.deleteTab(tab.id); expect(await colaStock(), 100); expect(await outbox(), hasLength(4)); }); test('a line whose product is gone changes no stock', () async { final tab = await tabWithTwoColas(); await (database.delete( database.products, )..where((row) => row.id.equals(_cola.id))).go(); await service.deleteTab(tab.id); expect((await outbox()).first['type'], 'line_removed'); expect(await service.getTabById(tab.id), isNull); }); test('the stock only changes when its event is stored', () async { service = DriftBarTabService( database: database, eventFactory: _FixedIdEventFactory(), ); final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); // The second event reuses the id, so storing it fails await expectLater( service.addProductToTab(tabId: tab.id, product: _cola), throwsA(anything), ); expect(await colaStock(), 99); expect(await outbox(), hasLength(1)); }); }); group('product events', () { late DriftProductService products; setUp(() { products = DriftProductService( database: database, eventFactory: TabEventFactory( now: () => DateTime.utc(2026, 10, 24, 15, 3, 40), ), ); }); Future>> productEvents() async => [ for (final event in await outbox()) if ((event['type'] as String).startsWith('product_')) event, ]; Future> uploads() => database.select(database.pendingImageUploads).get(); test('a new product goes out with its fields, then its stock', () async { await products.createProduct( name: 'Ice Coffee', category: 'Koffie', stockQuantity: 12, lowStockThreshold: 3, priceInCents: 350, imagePath: null, ); final id = (await products.getProducts()) .firstWhere((p) => p.name == 'Ice Coffee') .id; final events = await outbox(); expect(events.map((e) => e['type']), ['product_created', 'stock_set']); expect(events.first, { 'id': events.first['id'], 'type': 'product_created', 'productId': id, 'product': { 'name': 'Ice Coffee', 'category': 'Koffie', 'priceInCents': 350, 'lowStockThreshold': 3, }, 'occurredAt': '2026-10-24T15:03:40.000Z', }); expect(events.first.containsKey('tabId'), isFalse); }); test( 'an untracked new product has no count, and no category is null', () async { await products.createProduct( name: 'Tap water', category: ' ', stockQuantity: 0, lowStockThreshold: 0, infiniteStock: true, priceInCents: 0, imagePath: null, ); final events = await outbox(); expect(events.map((e) => e['type']), ['product_created']); expect(events.single['product'], containsPair('category', null)); }, ); test('a change sends only the fields that changed', () async { final cola = (await products.getProductById('prod-1'))!; await products.updateProduct( cola.copyWith(name: 'Coca-Cola', priceInCents: 270), ); expect( (await productEvents()).single, containsPair('product', {'name': 'Coca-Cola', 'priceInCents': 270}), ); }); test('saving without changes sends nothing', () async { final cola = (await products.getProductById('prod-1'))!; await products.updateProduct(cola); expect(await outbox(), isEmpty); }); test('deleting archives it for every tablet', () async { await products.deleteProduct('prod-1'); expect( (await productEvents()).single, containsPair('type', 'product_archived'), ); expect(await products.getProducts(), isEmpty); }); test( 'a picked picture waits for upload, a newer one replaces it', () async { final cola = (await products.getProductById('prod-1'))!; await products.updateProduct( cola.copyWith(imagePath: '/pictures/a.jpg'), ); await products.updateProduct( cola.copyWith(imagePath: '/pictures/b.jpg'), ); // Saving again with the same picture uploads nothing new await products.updateProduct( cola.copyWith(imagePath: '/pictures/b.jpg', name: 'Coke'), ); final waiting = await uploads(); expect(waiting.single.productId, 'prod-1'); expect(waiting.single.localPath, '/pictures/b.jpg'); // The picture itself is linked once uploaded, not now expect( (await productEvents()).where( (e) => (e['product'] as Map?)?.containsKey('imageId') ?? false, ), isEmpty, ); }, ); test('a new product with a picture queues it for upload', () async { await products.createProduct( name: 'Ice Coffee', category: 'Koffie', stockQuantity: 12, lowStockThreshold: 3, priceInCents: 350, imagePath: '/pictures/ice.jpg', ); expect((await uploads()).single.localPath, '/pictures/ice.jpg'); }); test('are skipped when replaying the outbox onto server tabs', () async { final tab = await openTab('Jan'); await products.deleteProduct('prod-1'); await service.applyServerState([]); expect(await service.getTabById(tab.id), isNull); expect(await productEvents(), hasLength(1)); }); }); group('stock_set events', () { late DriftProductService products; setUp(() { products = DriftProductService( database: database, eventFactory: TabEventFactory( now: () => DateTime.utc(2026, 10, 24, 15, 3, 40), ), ); }); test('have a product and quantity but no tab', () { final event = TabEventFactory( now: () => DateTime.utc(2026, 10, 24, 15, 3, 40), ).stockSet(productId: 'prod-1', quantity: 30); expect(event.payload, { 'id': event.id, 'type': 'stock_set', 'productId': 'prod-1', 'quantity': 30, 'occurredAt': '2026-10-24T15:03:40.000Z', }); }); test('a new product queues its starting stock', () async { await products.createProduct( name: 'Water', category: 'Drinks', stockQuantity: 24, lowStockThreshold: 5, priceInCents: 200, imagePath: null, ); await products.createProduct( name: 'Tap water', category: 'Drinks', stockQuantity: 0, lowStockThreshold: 0, infiniteStock: true, priceInCents: 0, imagePath: null, ); final water = (await products.getProducts()).firstWhere( (product) => product.name == 'Water', ); final counts = [ for (final event in await outbox()) if (event['type'] == 'stock_set') event, ]; expect(counts, hasLength(1)); expect(counts.single, containsPair('productId', water.id)); expect(counts.single, containsPair('quantity', 24)); }); test('a stock or tracking change queues a count', () async { final cola = (await products.getProductById('prod-1'))!; final renamed = cola.copyWith(name: 'Coca-Cola'); await products.updateProduct(renamed); expect( (await outbox()).where((event) => event['type'] == 'stock_set'), isEmpty, ); await products.updateProduct(renamed.copyWith(stockQuantity: 40)); await products.updateProduct( renamed.copyWith(stockQuantity: 40, infiniteStock: true), ); await products.updateProduct(renamed.copyWith(stockQuantity: 40)); expect( [ for (final event in await outbox()) if (event['type'] == 'stock_set') event['quantity'], ], [40, null, 40], ); }); test('are skipped when replaying the outbox onto server tabs', () async { final tab = await openTab('Jan'); await products.updateProduct( (await products.getProductById('prod-1'))!.copyWith(stockQuantity: 5), ); await service.applyServerState([]); expect(await service.getTabById(tab.id), isNull); expect(await outbox(), hasLength(1)); }); }); test('occurredAt is the UTC time it happened', () { final factory = TabEventFactory( now: () => DateTime.utc(2026, 10, 24, 21, 41, 3, 120), ); final event = factory.lineAdded( tabId: 'a1b2', customerName: 'Jan', lineId: 'line-1', productId: 'prod-1', productName: 'Cola', unitPriceInCents: 250, quantity: 2, ); expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z'); expect( () => factory.lineRemoved( tabId: 'a1b2', customerName: 'Jan', lineId: 'line-1', productId: 'prod-1', productName: 'Cola', unitPriceInCents: 250, quantity: 0, ), throwsArgumentError, ); }); } /// Gives every event the same id, so the second one can't be stored. class _FixedIdEventFactory extends TabEventFactory { @override TabEvent lineAdded({ required String tabId, required String customerName, required String lineId, required String productId, required String productName, required int unitPriceInCents, required int quantity, }) { final event = super.lineAdded( tabId: tabId, customerName: customerName, lineId: lineId, productId: productId, productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, ); return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'}); } }