feat: add historical data

This commit is contained in:
2026-10-19 23:31:54 +02:00
parent b0074aeb3f
commit 5754ae742d
10 changed files with 1211 additions and 4 deletions
+272
View File
@@ -0,0 +1,272 @@
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/payment_method.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/models/tab_event.dart';
import 'package:kooltap/services/bar_tab_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;
}
Future<BarTabRow> tabWithTwoColas() async {
final tab = await service.createTab(customerName: '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 service.createTab(customerName: '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['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('adding to an existing line keeps that line\'s price', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
await service.addProductToTab(
tabId: tab.id,
product: const Product(
id: 'prod-1',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 300,
),
);
final events = await outbox();
expect(events.map((event) => event['unitPriceInCents']), [250, 250]);
});
test('changing a quantity writes the difference', () async {
final tab = await service.createTab(customerName: '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 service.createTab(customerName: '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', () async {
final tab = await tabWithTwoColas();
await service.deleteTab(tab.id);
final event = (await outbox()).single;
expect(event['type'], 'line_removed');
expect(event['quantity'], 2);
expect(event['customerName'], 'Jan');
});
test('later events use the new name after a rename', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.renameTab(tabId: tab.id, customerName: 'Janneke');
expect(await outbox(), isEmpty);
await service.addProductToTab(tabId: tab.id, product: _cola);
expect((await outbox()).single['customerName'], '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('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'], [
{'productName': 'Cola', 'quantity': 2, 'unitPriceInCents': 250},
]);
});
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 service.createTab(customerName: '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');
});
});
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',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
);
expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z');
expect(
() => factory.lineRemoved(
tabId: 'a1b2',
customerName: 'Jan',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 0,
),
throwsArgumentError,
);
});
}