import 'dart:math'; 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/payment_method.dart'; import 'package:kooltap/models/product.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/services/product_service.dart'; const _beer = Product( id: 'beer', name: 'Beer', category: 'Drinks', stockQuantity: 50, lowStockThreshold: 5, priceInCents: 250, ); const _wine = Product( id: 'wine', name: 'Wine', category: 'Drinks', stockQuantity: 20, lowStockThreshold: 5, priceInCents: 450, ); const _water = Product( id: 'water', name: 'Tap water', category: 'Drinks', stockQuantity: 0, lowStockThreshold: 0, infiniteStock: true, priceInCents: 0, ); /// End-to-end checks on what customers are charged and what leaves the /// fridge, against a real (in-memory) database. void main() { late AppDatabase database; late DriftBarTabService tabs; late DriftProductService products; Future insertProduct(Product product) { return database .into(database.products) .insert( ProductsCompanion.insert( id: product.id, name: product.name, category: product.category, stockQuantity: product.stockQuantity, lowStockThreshold: product.lowStockThreshold, infiniteStock: Value(product.infiniteStock), priceInCents: product.priceInCents, ), ); } Future stockOf(String productId) async { return (await products.getProductById(productId))!.stockQuantity; } Future quantityOnOpenTabs(String productId) async { final open = await tabs.getOpenTabs(); return open .expand((tab) => tab.items) .where((item) => item.productId == productId) .fold(0, (sum, item) => sum + item.quantity); } Future soldInHistory(String productId) async { final closed = await tabs.getClosedTabs(); return closed .expand((tab) => tab.items) .where((item) => item.productId == productId && !item.isRemoved) .fold(0, (sum, item) => sum + item.quantity); } Future itemId(String tabId, String productId) async { final tab = (await tabs.getTabById(tabId))!; return tab.items.firstWhere((item) => item.productId == productId).id; } setUp(() async { database = AppDatabase(NativeDatabase.memory()); tabs = DriftBarTabService(database: database); products = DriftProductService(database: database); await insertProduct(_beer); await insertProduct(_wine); await insertProduct(_water); }); tearDown(() async { await database.close(); }); group('closing a tab', () { test('charges exactly what the open tab showed', () async { final tab = await tabs.createTab(customerName: 'John'); for (var i = 0; i < 3; i++) { await tabs.addProductToTab(tabId: tab.id, product: _beer); } await tabs.addProductToTab(tabId: tab.id, product: _wine); await tabs.adjustTabItemQuantity( tabItemId: await itemId(tab.id, 'beer'), delta: -1, ); final open = (await tabs.getTabById(tab.id))!; expect(open.totalInCents, 2 * 250 + 450); await tabs.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq); final closed = (await tabs.getClosedTabs()).single; expect(closed.totalInCents, open.totalInCents); expect(closed.itemCount, open.itemCount); expect(closed.paymentMethod, PaymentMethod.payconiq); expect(closed.items.where((item) => item.isRemoved), hasLength(1)); }); test('stores each payment method as chosen', () async { for (final method in PaymentMethod.values) { final tab = await tabs.createTab(customerName: method.name); await tabs.addProductToTab(tabId: tab.id, product: _beer); await tabs.closeTab(tab.id, paymentMethod: method); } final closed = await tabs.getClosedTabs(); expect( {for (final tab in closed) tab.customerName: tab.paymentMethod}, {for (final method in PaymentMethod.values) method.name: method}, ); }); test('does not record anything for an empty tab', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.closeTab(tab.id); expect(await tabs.getClosedTabs(), isEmpty); }); test('records a tab whose drinks were all undone at zero', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _beer); await tabs.adjustTabItemQuantity( tabItemId: await itemId(tab.id, 'beer'), delta: -1, ); await tabs.closeTab(tab.id); final closed = (await tabs.getClosedTabs()).single; expect(closed.totalInCents, 0); expect(closed.items.single.isRemoved, true); }); test('never charges the same drink twice', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _beer); await tabs.closeTab(tab.id); await tabs.addProductToTab(tabId: tab.id, product: _wine); await tabs.closeTab(tab.id); await tabs.closeTab(tab.id); final closed = await tabs.getClosedTabs(); expect(closed, hasLength(2)); expect( closed.map((tab) => tab.totalInCents), unorderedEquals([250, 450]), ); expect((await tabs.getTabById(tab.id))!.items, isEmpty); }); test('keeps the tab open under the same name', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _beer); await tabs.closeTab(tab.id); final reopened = (await tabs.getTabById(tab.id))!; expect(reopened.customerName, 'John'); expect(reopened.totalInCents, 0); }); test('a failed version check changes nothing', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _beer); final staleVersion = (await tabs.getTabById(tab.id))!.version; await tabs.addProductToTab(tabId: tab.id, product: _beer); await expectLater( tabs.closeTab(tab.id, expectedVersion: staleVersion), throwsA(isA()), ); expect(await tabs.getClosedTabs(), isEmpty); expect((await tabs.getTabById(tab.id))!.totalInCents, 500); }); }); group('prices', () { test('a tab keeps the price a drink was rung up at', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _beer); await products.updateProduct(_beer.copyWith(priceInCents: 300)); await tabs.closeTab(tab.id); expect((await tabs.getClosedTabs()).single.totalInCents, 250); }); test('a new tab uses the current price', () async { final updated = _beer.copyWith(priceInCents: 300); await products.updateProduct(updated); final tab = await tabs.createTab(customerName: 'Jane'); await tabs.addProductToTab(tabId: tab.id, product: updated); expect((await tabs.getTabById(tab.id))!.totalInCents, 300); }); test('free products add nothing to the total', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _water); await tabs.addProductToTab(tabId: tab.id, product: _beer); final open = (await tabs.getTabById(tab.id))!; expect(open.totalInCents, 250); expect(open.itemCount, 2); }); }); group('stock', () { test('every bottle is either in the fridge, on a tab or sold', () async { final random = Random(42); final tabIds = [ for (final name in ['A', 'B', 'C', 'D']) (await tabs.createTab(customerName: name)).id, ]; for (var step = 0; step < 300; step++) { final tabId = tabIds[random.nextInt(tabIds.length)]; final product = random.nextBool() ? _beer : _wine; try { switch (random.nextInt(10)) { case < 6: await tabs.addProductToTab(tabId: tabId, product: product); case < 8: final tab = (await tabs.getTabById(tabId))!; if (tab.items.isEmpty) continue; final item = tab.items[random.nextInt(tab.items.length)]; await tabs.adjustTabItemQuantity( tabItemId: item.id, delta: random.nextInt(5) - 2, ); case 8: await tabs.closeTab(tabId); default: await tabs.deleteTab(tabId); tabIds.remove(tabId); tabIds.add((await tabs.createTab(customerName: 'New')).id); } } on InsufficientStockException { // Running out is part of the scenario. } for (final product in [_beer, _wine]) { final accounted = await stockOf(product.id) + await quantityOnOpenTabs(product.id) + await soldInHistory(product.id); expect(accounted, product.stockQuantity, reason: 'step $step'); expect(await stockOf(product.id), greaterThanOrEqualTo(0)); } } }); test('can never sell more than is in stock', () async { final tab = await tabs.createTab(customerName: 'John'); var sold = 0; for (var i = 0; i < _wine.stockQuantity + 5; i++) { try { await tabs.addProductToTab(tabId: tab.id, product: _wine); sold++; } on InsufficientStockException { // Expected once the wine runs out. } } expect(sold, _wine.stockQuantity); expect(await stockOf('wine'), 0); expect((await tabs.getTabById(tab.id))!.totalInCents, 20 * 450); }); test('simultaneous orders for the last bottle sell it once', () async { await products.updateProduct(_wine.copyWith(stockQuantity: 1)); final first = await tabs.createTab(customerName: 'A'); final second = await tabs.createTab(customerName: 'B'); final results = await Future.wait([ for (final tab in [first, second]) tabs .addProductToTab(tabId: tab.id, product: _wine) .then((_) => true) .catchError( (_) => false, test: (e) => e is InsufficientStockException, ), ]); expect(results.where((ok) => ok), hasLength(1)); expect(await stockOf('wine'), 0); expect(await quantityOnOpenTabs('wine'), 1); }); test('raising a quantity past the stock adds nothing', () async { await products.updateProduct(_wine.copyWith(stockQuantity: 2)); final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _wine); await expectLater( tabs.adjustTabItemQuantity( tabItemId: await itemId(tab.id, 'wine'), delta: 5, ), throwsA(isA()), ); expect(await stockOf('wine'), 1); expect((await tabs.getTabById(tab.id))!.items.single.quantity, 1); }); test('lowering past zero only returns what was on the tab', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _beer); await tabs.addProductToTab(tabId: tab.id, product: _beer); final applied = await tabs.adjustTabItemQuantity( tabItemId: await itemId(tab.id, 'beer'), delta: -10, ); expect(applied, -2); expect(await stockOf('beer'), _beer.stockQuantity); }); test('closing a tab does not touch stock', () async { final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _beer); await tabs.closeTab(tab.id); expect(await stockOf('beer'), _beer.stockQuantity - 1); }); test('infinite stock is never counted down or run out', () async { final tab = await tabs.createTab(customerName: 'John'); for (var i = 0; i < 5; i++) { await tabs.addProductToTab(tabId: tab.id, product: _water); } await tabs.deleteTab(tab.id); expect(await stockOf('water'), 0); }); test('a failed add leaves no item behind', () async { await products.updateProduct(_wine.copyWith(stockQuantity: 0)); final tab = await tabs.createTab(customerName: 'John'); await expectLater( tabs.addProductToTab(tabId: tab.id, product: _wine), throwsA(isA()), ); final open = (await tabs.getTabById(tab.id))!; expect(open.items, isEmpty); expect(open.totalInCents, 0); }); }); }