feat: add historical data
This commit is contained in:
@@ -89,4 +89,9 @@ void main() {
|
||||
|
||||
expect(orphan, isNull);
|
||||
});
|
||||
|
||||
test('creates an empty tab event outbox', () async {
|
||||
expect(await database.select(database.tabEvents).get(), isEmpty);
|
||||
expect((await database.select(database.barTabs).get()), hasLength(1));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -443,4 +443,152 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('tab events', () {
|
||||
Future<void> queueEvent(String id, DateTime createdAt) async {
|
||||
await database
|
||||
.into(database.tabEvents)
|
||||
.insert(
|
||||
TabEventsCompanion.insert(
|
||||
id: id,
|
||||
payload: jsonEncode({'id': id, 'type': 'line_added'}),
|
||||
createdAt: createdAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> outboxIds() async {
|
||||
final rows = await database.select(database.tabEvents).get();
|
||||
return [for (final row in rows) row.id];
|
||||
}
|
||||
|
||||
List<String> sentIds([int request = 0]) => [
|
||||
for (final event in server.syncRequests[request]['events'] as List)
|
||||
event['id'] as String,
|
||||
];
|
||||
|
||||
test('sends at most 500 events, oldest first', () async {
|
||||
final start = DateTime(2026, 10, 24, 20);
|
||||
// Inserted newest first, so only the timestamps give the order
|
||||
for (var i = 501; i >= 1; i--) {
|
||||
await queueEvent('e$i', start.add(Duration(minutes: i)));
|
||||
}
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(sentIds(), [for (var i = 1; i <= 500; i++) 'e$i']);
|
||||
expect(server.syncRequests.single['events'].first, {
|
||||
'id': 'e1',
|
||||
'type': 'line_added',
|
||||
});
|
||||
});
|
||||
|
||||
test('deletes exactly the acknowledged and rejected events', () async {
|
||||
final now = DateTime.now();
|
||||
for (final id in ['e1', 'e2', 'e3']) {
|
||||
await queueEvent(id, now);
|
||||
}
|
||||
server.respondWith({
|
||||
'acknowledgedEventIds': ['e1'],
|
||||
'rejectedEvents': [
|
||||
{'id': 'e2', 'reason': 'unknown type'},
|
||||
{'reason': 'not an object'},
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(await outboxIds(), ['e3']);
|
||||
});
|
||||
|
||||
test('a failed sync deletes nothing', () async {
|
||||
await queueEvent('e1', DateTime.now());
|
||||
server.syncResponse = http.Response(
|
||||
jsonEncode({
|
||||
'acknowledgedEventIds': ['e1'],
|
||||
}),
|
||||
500,
|
||||
);
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(await outboxIds(), ['e1']);
|
||||
});
|
||||
|
||||
test('an older server without event fields keeps the events', () async {
|
||||
await queueEvent('e1', DateTime.now());
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(await outboxIds(), ['e1']);
|
||||
});
|
||||
|
||||
test('events created during the sync wait for the next one', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
server.holdSync = Completer<void>();
|
||||
|
||||
final running = service.sync();
|
||||
await pumpEventQueue();
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
server.respondWith({'acknowledgedEventIds': sentIds()});
|
||||
server.holdSync!.complete();
|
||||
server.holdSync = null;
|
||||
await running;
|
||||
|
||||
expect(sentIds(), hasLength(1));
|
||||
final remaining = await outboxIds();
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining, isNot(contains(sentIds().single)));
|
||||
});
|
||||
|
||||
test('a close_tab command writes one settled event', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
final current = (await barTabService.getTabById(tab.id))!;
|
||||
server.respondWith({
|
||||
'commands': [
|
||||
_closeCommand(
|
||||
id: 'cmd-1',
|
||||
tabId: tab.id,
|
||||
expectedVersion: current.version,
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// The second sync receives the same command again
|
||||
await service.sync();
|
||||
await service.sync();
|
||||
|
||||
final settled = [
|
||||
for (final event in server.syncRequests.last['events'] as List)
|
||||
if (event['type'] == 'settled') event,
|
||||
];
|
||||
expect(settled, hasLength(1));
|
||||
expect(settled.single['commandId'], 'cmd-1');
|
||||
expect(settled.single['paymentMethod'], 'payconiq');
|
||||
expect(settled.single['amountInCents'], 800);
|
||||
expect(settled.single['customerName'], 'Alice');
|
||||
});
|
||||
|
||||
test('a conflicting close_tab command writes no settled event', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
server.respondWith({
|
||||
'commands': [
|
||||
_closeCommand(id: 'cmd-1', tabId: tab.id, expectedVersion: 1),
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
await service.sync();
|
||||
|
||||
final types = [
|
||||
for (final event in server.syncRequests.last['events'] as List)
|
||||
event['type'],
|
||||
];
|
||||
expect(types, ['line_added']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user