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/product.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/services/product_service.dart'; 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: 'prod-1', name: 'Test Beer', category: 'Drinks', stockQuantity: 100, lowStockThreshold: 10, priceInCents: 400, ), ); }); tearDown(() async { await database.close(); }); Product createTestProduct({ String id = 'prod-1', String name = 'Test Beer', int priceInCents = 400, }) { return Product( id: id, name: name, category: 'Drinks', stockQuantity: 100, lowStockThreshold: 10, priceInCents: priceInCents, ); } group('DriftBarTabService', () { group('createTab', () { test('creates an empty tab at version 1', () async { final tab = await service.createTab(customerName: 'John'); expect(tab.customerName, 'John'); expect(tab.version, 1); expect(tab.items, isEmpty); }); test('rejects a blank customer name', () async { await expectLater( service.createTab(customerName: ' '), throwsA(isA()), ); }); test('creates tab with unique id', () async { final tab1 = await service.createTab(customerName: 'John'); final tab2 = await service.createTab(customerName: 'Jane'); expect(tab1.id, isNot(equals(tab2.id))); }); }); group('getOpenTabs', () { test('returns empty list when no tabs', () async { final tabs = await service.getOpenTabs(); expect(tabs, isEmpty); }); test('tabs sorted by openedAt descending', () async { final now = DateTime.now(); await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: 'tab-first', customerName: 'First', openedAt: now.subtract(const Duration(hours: 1)), ), ); await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: 'tab-second', customerName: 'Second', openedAt: now, ), ); final tabs = await service.getOpenTabs(); expect(tabs.first.customerName, 'Second'); expect(tabs.last.customerName, 'First'); }); }); group('getTabById', () { test('returns tab when exists', () async { final created = await service.createTab(customerName: 'Test'); final tab = await service.getTabById(created.id); expect(tab, isNotNull); expect(tab!.customerName, 'Test'); }); test('returns null when not exists', () async { final tab = await service.getTabById('nonexistent'); expect(tab, isNull); }); }); group('renameTab', () { test('updates the tab customer name', () async { final tab = await service.createTab(customerName: 'John'); await service.renameTab(tabId: tab.id, customerName: ' Jane '); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.customerName, 'Jane'); }); }); group('deleteTab', () { test('deletes the tab and restores item stock', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); await service.deleteTab(tab.id); expect(await service.getTabById(tab.id), isNull); final updatedProduct = await (database.select( database.products, )..where((row) => row.id.equals(product.id))).getSingle(); expect(updatedProduct.stockQuantity, 100); expect(await database.select(database.tabItems).get(), isEmpty); expect(await database.select(database.tabItemPurchases).get(), isEmpty); }); }); group('addProductToTab', () { test('adds new product to tab', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.length, 1); expect(updatedTab.items.first.productName, 'Test Beer'); expect(updatedTab.items.first.quantity, 1); }); test('increments quantity when product already in tab', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.length, 1); expect(updatedTab.items.first.quantity, 3); }); test('records each addition as a separate purchase event', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); final barTab = (await service.getOpenTabs()).first; final historyTab = (await service.getOpenTabs( includeRemoved: true, )).first; expect(barTab.items.first.quantity, 2); expect(barTab.items.first.purchases, hasLength(2)); expect(historyTab.items.first.purchases, hasLength(2)); expect( historyTab.items.first.purchases.every( (purchase) => !purchase.isRemoved, ), isTrue, ); }); test('adding product calculates correct line total', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(priceInCents: 450); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.first.lineTotalInCents, 900); }); test('decreases stock as part of adding the item', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final storedProduct = await (database.select( database.products, )..where((row) => row.id.equals(product.id))).getSingle(); expect(storedProduct.stockQuantity, 99); }); test('still sells when the stock is at zero', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await (database.update(database.products) ..where((row) => row.id.equals(product.id))) .write(const ProductsCompanion(stockQuantity: Value(0))); await service.addProductToTab(tabId: tab.id, product: product); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.single.quantity, 1); final storedProduct = await (database.select( database.products, )..where((row) => row.id.equals(product.id))).getSingle(); expect(storedProduct.stockQuantity, -1); }); }); // The website changes prices: drinks already on a tab keep theirs, the // next one is charged the new price group('addProductToTab after a price or name change', () { test( 'a new price starts a new line; earlier drinks keep theirs', () async { final tab = await service.createTab(customerName: 'John'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(priceInCents: 450), ); final items = (await service.getTabById(tab.id))!.items; expect([ for (final item in items) (item.unitPriceInCents, item.quantity), ], unorderedEquals([(400, 2), (450, 1)])); expect((await service.getTabById(tab.id))!.totalInCents, 1250); }, ); test('more drinks at the new price go on the new line', () async { final tab = await service.createTab(customerName: 'John'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); for (var i = 0; i < 3; i++) { await service.addProductToTab( tabId: tab.id, product: createTestProduct(priceInCents: 450), ); } final items = (await service.getTabById(tab.id))!.items; expect(items, hasLength(2)); expect( items.firstWhere((item) => item.unitPriceInCents == 450).quantity, 3, ); }); test('a price going back joins the line at that price again', () async { final tab = await service.createTab(customerName: 'John'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(priceInCents: 300), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); final items = (await service.getTabById(tab.id))!.items; expect([ for (final item in items) (item.unitPriceInCents, item.quantity), ], unorderedEquals([(400, 2), (300, 1)])); }); test('a renamed product starts a new line under its new name', () async { final tab = await service.createTab(customerName: 'John'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(name: 'Pils'), ); final items = (await service.getTabById(tab.id))!.items; expect([ for (final item in items) item.productName, ], unorderedEquals(['Test Beer', 'Pils'])); }); test('both lines count down the stock of the one product', () async { final tab = await service.createTab(customerName: 'John'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(priceInCents: 450), ); final stored = await (database.select( database.products, )..where((row) => row.id.equals('prod-1'))).getSingle(); expect(stored.stockQuantity, 98); }); test('settling charges each line at its own price', () async { final tab = await service.createTab(customerName: 'John'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(priceInCents: 450), ); await service.closeTab(tab.id); final closed = (await service.getClosedTabs()).single; expect(closed.totalInCents, 850); expect([ for (final item in closed.items) item.unitPriceInCents, ], unorderedEquals([400, 450])); }); }); // Foo has 2 at the old price; the website changes the price; staff tap + // on Foo's line. The 2 stay at the old price, every new one is charged // the new price. group('+ on a line after a price change', () { Future setPrice(int priceInCents, {String? name}) async { await (database.update( database.products, )..where((row) => row.id.equals('prod-1'))).write( ProductsCompanion( priceInCents: Value(priceInCents), name: name == null ? const Value.absent() : Value(name), ), ); } Future fooWithTwoAtOldPrice() async { final tab = await service.createTab(customerName: 'Foo'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await setPrice(450); return tab.id; } Future> lines(String tabId) async { return [ for (final item in (await service.getTabById(tabId))!.items) (item.unitPriceInCents, item.quantity), ]; } Future lineAt(String tabId, int priceInCents) async { return (await service.getTabById(tabId))!.items .firstWhere((item) => item.unitPriceInCents == priceInCents) .id; } test( 'the old drinks keep their price, the new one gets the new price', () async { final tabId = await fooWithTwoAtOldPrice(); final added = await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 1, ); expect(added, 1); expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)])); expect((await service.getTabById(tabId))!.totalInCents, 1250); }, ); test( 'tapping + on the old line again keeps adding at the new price', () async { final tabId = await fooWithTwoAtOldPrice(); final oldLine = await lineAt(tabId, 400); await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1); await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1); expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)])); }, ); test('+ on the line at the new price adds there', () async { final tabId = await fooWithTwoAtOldPrice(); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 1, ); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 450), delta: 1, ); expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)])); }); test('the tile and + end up on the same new line', () async { final tabId = await fooWithTwoAtOldPrice(); await service.addProductToTab( tabId: tabId, product: createTestProduct(priceInCents: 450), ); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 1, ); expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)])); }); test('+ several at once adds them all at the new price', () async { final tabId = await fooWithTwoAtOldPrice(); final added = await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 3, ); expect(added, 3); expect(await lines(tabId), unorderedEquals([(400, 2), (450, 3)])); }); test('− on the old line takes off a drink at the old price', () async { final tabId = await fooWithTwoAtOldPrice(); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 1, ); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: -1, ); expect(await lines(tabId), unorderedEquals([(400, 1), (450, 1)])); }); test('a price going back makes + add to the old line again', () async { final tabId = await fooWithTwoAtOldPrice(); await setPrice(400); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 1, ); expect(await lines(tabId), [(400, 3)]); }); test('a renamed product gets a new line under its new name', () async { final tab = await service.createTab(customerName: 'Foo'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await setPrice(400, name: 'Pils'); await service.adjustTabItemQuantity( tabItemId: (await service.getTabById(tab.id))!.items.single.id, delta: 1, ); final names = [ for (final item in (await service.getTabById(tab.id))!.items) (item.productName, item.quantity), ]; expect(names, unorderedEquals([('Test Beer', 1), ('Pils', 1)])); }); test( 'a hidden (archived) product is still charged its latest price', () async { final tabId = await fooWithTwoAtOldPrice(); await (database.update(database.products) ..where((row) => row.id.equals('prod-1'))) .write(const ProductsCompanion(active: Value(false))); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 1, ); expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)])); }, ); test('a line whose product is gone keeps its own price', () async { final tab = await service.createTab(customerName: 'Foo'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await database.delete(database.products).go(); await service.adjustTabItemQuantity( tabItemId: (await service.getTabById(tab.id))!.items.single.id, delta: 1, ); expect(await lines(tab.id), [(400, 2)]); }); test('another tab without the product gets the new price', () async { await fooWithTwoAtOldPrice(); final bar = await service.createTab(customerName: 'Bar'); await service.addProductToTab( tabId: bar.id, product: createTestProduct(priceInCents: 450), ); expect(await lines(bar.id), [(450, 1)]); }); test('every drink counts down the stock of the one product', () async { final tabId = await fooWithTwoAtOldPrice(); await service.adjustTabItemQuantity( tabItemId: await lineAt(tabId, 400), delta: 2, ); final stored = await (database.select( database.products, )..where((row) => row.id.equals('prod-1'))).getSingle(); expect(stored.stockQuantity, 96); }); }); group('adjustTabItemQuantity', () { test('updates item quantity', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 4); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.first.quantity, 5); }); test('hides item when the delta removes all quantity', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items, isEmpty); }); test('restores stock when quantity decreases', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items, isEmpty); final updatedProduct = await (database.select( database.products, )..where((row) => row.id.equals(product.id))).getSingle(); expect(updatedProduct.stockQuantity, 100); }); test('keeps a removed purchase in the history audit trail', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1); final historyTab = (await service.getOpenTabs( includeRemoved: true, )).first; final item = historyTab.items.first; expect(item.quantity, 1); expect( item.purchases.where((purchase) => purchase.isRemoved), hasLength(1), ); expect( item.purchases.where((purchase) => !purchase.isRemoved), hasLength(1), ); }); test( 'applies concurrent deltas without losing stock consistency', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; final deltas = await Future.wait([ service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 1), service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 1), ]); expect(deltas, [1, 1]); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.first.quantity, 3); final updatedProduct = await (database.select( database.products, )..where((row) => row.id.equals(product.id))).getSingle(); expect(updatedProduct.stockQuantity, 97); }, ); }); group('closeTab', () { test('archives tab items to closed tabs', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); await service.closeTab(tab.id); final closedTabs = await service.getClosedTabs(); expect(closedTabs.length, 1); expect(closedTabs.first.customerName, 'John'); expect(closedTabs.first.items.length, 2); expect( closedTabs.first.items.every((item) => item.quantity == 1), true, ); expect( closedTabs.first.items.every((item) => item.purchasedAt != null), true, ); }); test( 'keeps removed purchases visible but excludes them from totals', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1); await service.closeTab(tab.id); final closedTab = (await service.getClosedTabs()).first; expect(closedTab.items, hasLength(2)); expect(closedTab.items.where((item) => item.isRemoved), hasLength(1)); expect(closedTab.itemCount, 1); expect(closedTab.totalInCents, 400); }, ); test('clears tab items after closing', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.closeTab(tab.id); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items, isEmpty); }); test( 'keeps the tab under the same name and drops its purchases', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await service.closeTab(tab.id); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.customerName, 'John'); expect(updatedTab.items, isEmpty); expect( await database.select(database.tabItemPurchases).get(), isEmpty, ); }, ); test('throws when the tab changed since the expected version', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); await expectLater( service.closeTab(tab.id, expectedVersion: tab.version), throwsA(isA()), ); expect((await service.getTabById(tab.id))!.items, hasLength(1)); expect(await service.getClosedTabs(), isEmpty); }); test('closes when the expected version matches', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final current = await service.getTabById(tab.id); await service.closeTab(tab.id, expectedVersion: current!.version); expect(await service.getClosedTabs(), hasLength(1)); }); test('throws for an unknown tab', () async { await expectLater( service.closeTab('missing'), throwsA(isA()), ); }); test('closed tab preserves item prices', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(priceInCents: 550); await service.addProductToTab(tabId: tab.id, product: product); await service.closeTab(tab.id); final closedTabs = await service.getClosedTabs(); expect(closedTabs.first.items.first.unitPriceInCents, 550); }); }); group('versioning', () { test('every change bumps the tab version', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final afterAdd = (await service.getTabById(tab.id))!; await service.adjustTabItemQuantity( tabItemId: afterAdd.items.first.id, delta: 1, ); final afterAdjust = (await service.getTabById(tab.id))!; await service.renameTab(tabId: tab.id, customerName: 'Jane'); final afterRename = (await service.getTabById(tab.id))!; expect(afterAdd.version, tab.version + 1); expect(afterAdjust.version, afterAdd.version + 1); expect(afterRename.version, afterAdjust.version + 1); }); }); group('watchOpenTabs', () { test('emits again when an item is added', () async { final tab = await service.createTab(customerName: 'John'); final emissions = service.watchOpenTabs(); final expectation = expectLater( emissions.map((tabs) => tabs.single.itemCount), emitsThrough(1), ); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await expectation; }); }); group('getDistinctCustomerNames', () { test('returns each closed customer once, sorted', () async { for (final name in ['Zoe', 'Adam', 'Zoe']) { final tab = await service.createTab(customerName: name); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await service.closeTab(tab.id); } expect(await service.getDistinctCustomerNames(), ['Adam', 'Zoe']); }); }); group('getClosedTabs', () { test('returns empty when no closed tabs', () async { final tabs = await service.getClosedTabs(); expect(tabs, isEmpty); }); test('returns closed tabs sorted by closedAt descending', () async { final now = DateTime.now(); await database .into(database.closedTabs) .insert( ClosedTabsCompanion.insert( id: 'closed-first', originalTabId: 'original-1', customerName: 'First', closedAt: now.subtract(const Duration(hours: 1)), ), ); await database .into(database.closedTabs) .insert( ClosedTabsCompanion.insert( id: 'closed-second', originalTabId: 'original-2', customerName: 'Second', closedAt: now, ), ); final closedTabs = await service.getClosedTabs(); expect(closedTabs.first.customerName, 'Second'); expect(closedTabs.last.customerName, 'First'); }); }); group('hidden products', () { test('a tab holding a deleted product can still be changed', () async { final tab = await service.createTab(customerName: 'John'); await service.addProductToTab( tabId: tab.id, product: createTestProduct(), ); await DriftProductService(database: database).deleteProduct('prod-1'); final item = (await service.getTabById(tab.id))!.items.single; await service.adjustTabItemQuantity(tabItemId: item.id, delta: -1); await service.deleteTab(tab.id); expect(await service.getTabById(tab.id), isNull); }); }); }); }