577 lines
18 KiB
Dart
577 lines
18 KiB
Dart
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<List<Map<String, dynamic>>> 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<String, dynamic>,
|
|
];
|
|
}
|
|
|
|
Future<String> lineIdOf(String tabId) async {
|
|
return (await service.getTabById(tabId))!.items.single.id;
|
|
}
|
|
|
|
/// Opens a tab and drops its `tab_opened` event.
|
|
Future<BarTab> openTab(String name) async {
|
|
final tab = await service.createTab(customerName: name);
|
|
await database.delete(database.tabEvents).go();
|
|
return tab;
|
|
}
|
|
|
|
Future<BarTabRow> 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<TabNotFoundException>()),
|
|
);
|
|
|
|
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<TabNotFoundException>()),
|
|
);
|
|
|
|
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<TabVersionConflictException>()),
|
|
);
|
|
|
|
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<int> 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('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 events = await outbox();
|
|
expect(events, hasLength(1));
|
|
expect(events.single, containsPair('type', 'stock_set'));
|
|
expect(events.single, containsPair('productId', water.id));
|
|
expect(events.single, containsPair('quantity', 24));
|
|
});
|
|
|
|
test('a stock or tracking change queues a count', () async {
|
|
final cola = (await products.getProductById('prod-1'))!;
|
|
|
|
await products.updateProduct(cola.copyWith(name: 'Coca-Cola'));
|
|
expect(await outbox(), isEmpty);
|
|
|
|
await products.updateProduct(cola.copyWith(stockQuantity: 40));
|
|
await products.updateProduct(
|
|
cola.copyWith(stockQuantity: 40, infiniteStock: true),
|
|
);
|
|
await products.updateProduct(cola.copyWith(stockQuantity: 40));
|
|
|
|
expect(
|
|
[for (final event in await outbox()) 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'});
|
|
}
|
|
}
|