391 lines
13 KiB
Dart
391 lines
13 KiB
Dart
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<void> 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<int> stockOf(String productId) async {
|
|
return (await products.getProductById(productId))!.stockQuantity;
|
|
}
|
|
|
|
Future<int> quantityOnOpenTabs(String productId) async {
|
|
final open = await tabs.getOpenTabs();
|
|
return open
|
|
.expand((tab) => tab.items)
|
|
.where((item) => item.productId == productId)
|
|
.fold<int>(0, (sum, item) => sum + item.quantity);
|
|
}
|
|
|
|
Future<int> soldInHistory(String productId) async {
|
|
final closed = await tabs.getClosedTabs();
|
|
return closed
|
|
.expand((tab) => tab.items)
|
|
.where((item) => item.productId == productId && !item.isRemoved)
|
|
.fold<int>(0, (sum, item) => sum + item.quantity);
|
|
}
|
|
|
|
Future<String> 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<TabVersionConflictException>()),
|
|
);
|
|
|
|
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<InsufficientStockException>()),
|
|
);
|
|
|
|
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<InsufficientStockException>()),
|
|
);
|
|
|
|
final open = (await tabs.getTabById(tab.id))!;
|
|
expect(open.items, isEmpty);
|
|
expect(open.totalInCents, 0);
|
|
});
|
|
});
|
|
}
|