feat: make server owner of tabs

This commit is contained in:
2026-10-20 16:15:14 +02:00
parent 5754ae742d
commit 93acfe15a9
26 changed files with 3654 additions and 389 deletions
+267 -19
View File
@@ -4,10 +4,12 @@ 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',
@@ -59,8 +61,15 @@ void main() {
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 service.createTab(customerName: 'Jan');
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();
@@ -71,13 +80,15 @@ void main() {
group('line events', () {
test('adding a product writes line_added with the common fields', () async {
final tab = await service.createTab(customerName: 'Jan');
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);
@@ -89,7 +100,7 @@ void main() {
});
test('adding to an existing line keeps that line\'s price', () async {
final tab = await service.createTab(customerName: 'Jan');
final tab = await openTab('Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
await service.addProductToTab(
tabId: tab.id,
@@ -108,7 +119,7 @@ void main() {
});
test('changing a quantity writes the difference', () async {
final tab = await service.createTab(customerName: 'Jan');
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);
@@ -137,7 +148,7 @@ void main() {
});
test('a change that does nothing writes no event', () async {
final tab = await service.createTab(customerName: 'Jan');
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);
@@ -149,25 +160,38 @@ void main() {
expect(await outbox(), isEmpty);
});
test('deleting a tab writes line_removed for each line', () async {
final tab = await tabWithTwoColas();
test(
'deleting a tab writes line_removed for each line, then tab_deleted',
() async {
final tab = await tabWithTwoColas();
await service.deleteTab(tab.id);
await service.deleteTab(tab.id);
final event = (await outbox()).single;
expect(event['type'], 'line_removed');
expect(event['quantity'], 2);
expect(event['customerName'], 'Jan');
});
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 service.createTab(customerName: 'Jan');
final tab = await openTab('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');
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 {
@@ -180,6 +204,29 @@ void main() {
});
});
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();
@@ -193,8 +240,16 @@ void main() {
expect(event['paymentMethod'], 'payconiq');
expect(event.containsKey('commandId'), false);
expect(event['items'], [
{'productName': 'Cola', 'quantity': 2, 'unitPriceInCents': 250},
{
'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 {
@@ -211,7 +266,7 @@ void main() {
});
test('settling an empty tab writes nothing', () async {
final tab = await service.createTab(customerName: 'Jan');
final tab = await openTab('Jan');
await service.closeTab(tab.id);
@@ -244,6 +299,170 @@ void main() {
});
});
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),
@@ -252,6 +471,8 @@ void main() {
final event = factory.lineAdded(
tabId: 'a1b2',
customerName: 'Jan',
lineId: 'line-1',
productId: 'prod-1',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
@@ -262,6 +483,8 @@ void main() {
() => factory.lineRemoved(
tabId: 'a1b2',
customerName: 'Jan',
lineId: 'line-1',
productId: 'prod-1',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 0,
@@ -270,3 +493,28 @@ void main() {
);
});
}
/// 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'});
}
}