feat: make server own products

This commit is contained in:
2026-10-21 12:10:42 +02:00
parent 93acfe15a9
commit b15d94a805
21 changed files with 1717 additions and 106 deletions
+501
View File
@@ -127,6 +127,35 @@ void main() {
];
group('sync', () {
test(
'a lost connection is reported as offline, a server error is not',
() async {
final offline = RemoteSyncService(
database: database,
barTabService: barTabService,
apiClient: KoolTapApiClient(
client: MockClient(
(_) async => throw http.ClientException('Failed host lookup'),
),
baseUrl: _baseUrl,
),
secureStorage: storage,
);
final offlineEvents = <RemoteSyncEvent>[];
offline.events.listen(offlineEvents.add);
server.syncResponse = http.Response('oops', 500);
await offline.sync();
await service.sync();
await pumpEventQueue();
expect(offlineEvents.single.type, RemoteSyncEventType.syncFailed);
expect(offlineEvents.single.offline, isTrue);
expect(events.single.offline, isFalse);
offline.dispose();
},
);
test('sends a snapshot of the open tabs and stores the cursor', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
@@ -1363,6 +1392,478 @@ void main() {
});
});
group('website manages the catalog', () {
Map<String, dynamic> product(
String id, {
String name = 'Cola',
String? category = 'Frisdrank',
int price = 250,
int? lowStockThreshold,
bool archived = false,
}) {
return {
'id': id,
'name': name,
'category': category,
'priceInCents': price,
'lowStockThreshold': lowStockThreshold,
'archived': archived,
};
}
Future<Map<String, ProductRow>> rows() async {
return {
for (final row in await database.select(database.products).get())
row.id: row,
};
}
test('adds, changes and hides products like the website says', () async {
await (database.update(database.products)
..where((row) => row.id.equals('prod-1')))
.write(const ProductsCompanion(imagePath: Value('/beer.png')));
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: 'seeded',
name: 'Seeded on this tablet',
category: 'Drinks',
stockQuantity: 24,
lowStockThreshold: 0,
priceInCents: 100,
),
);
server.respondWith({
'catalog': [
product(
'prod-1',
name: 'Beer',
category: null,
price: 450,
lowStockThreshold: 6,
),
product('cola'),
product('old', name: 'Old', archived: true),
],
'stock': [
{'productId': 'cola', 'stock': 24, 'countedAt': null},
],
});
await service.sync();
final products = await rows();
expect(
products['prod-1'],
isA<ProductRow>()
.having((p) => p.priceInCents, 'price', 450)
.having((p) => p.category, 'category', '')
.having((p) => p.lowStockThreshold, 'threshold', 6)
.having((p) => p.imagePath, 'image', '/beer.png')
.having((p) => p.active, 'active', isTrue),
);
// New, and its stock arrives in the same sync
expect(
products['cola'],
isA<ProductRow>()
.having((p) => p.name, 'name', 'Cola')
.having((p) => p.stockQuantity, 'stock', 24)
.having((p) => p.infiniteStock, 'untracked', isFalse),
);
expect(products['old']!.active, isFalse);
expect(products['seeded']!.active, isFalse);
});
test('a product that comes back is shown again', () async {
server.respondWith({
'catalog': [product('prod-1', archived: true)],
});
await service.sync();
expect((await rows())['prod-1']!.active, isFalse);
server.respondWith({
'catalog': [product('prod-1')],
});
await service.sync();
expect((await rows())['prod-1']!.active, isTrue);
});
test('a server without a catalog leaves the products alone', () async {
await service.sync();
expect(
(await rows())['prod-1'],
isA<ProductRow>()
.having((p) => p.name, 'name', 'Beer')
.having((p) => p.active, 'active', isTrue),
);
});
});
group('website catalog changes reach the bar', () {
late DriftProductService products;
setUp(() {
products = DriftProductService(database: database);
});
Map<String, dynamic> beer({
int price = 400,
String name = 'Beer',
bool archived = false,
int? lowStockThreshold = 10,
}) {
return {
'id': 'prod-1',
'name': name,
'category': 'Drinks',
'priceInCents': price,
'lowStockThreshold': lowStockThreshold,
'archived': archived,
};
}
List<Map<String, dynamic>> sentLines(int request) => [
for (final event in server.syncRequests[request]['events'] as List)
if (event['type'] == 'line_added') event as Map<String, dynamic>,
];
test(
'the next drink after a price change is charged the new price',
() async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(
tabId: tab.id,
product: (await products.getProductById('prod-1'))!,
);
server.respondWith({
'catalog': [beer(price: 450)],
});
await service.sync();
// What the bar screen hands to the tab: the product as stored now
final shown = (await products.getProducts()).single;
expect(shown.priceInCents, 450);
await barTabService.addProductToTab(tabId: tab.id, product: shown);
server.respondWith({});
await service.sync();
final lines = (await barTabService.getTabById(tab.id))!.items;
expect([
for (final line in lines) (line.unitPriceInCents, line.quantity),
], unorderedEquals([(400, 1), (450, 1)]));
// Unacknowledged, so both drinks are sent again, each at its own price
expect(
[for (final line in sentLines(1)) line['unitPriceInCents']],
[400, 450],
);
},
);
test('Foo keeps 2 at the old price, + charges the new price', () async {
final tab = await barTabService.createTab(customerName: 'Foo');
final beerNow = (await products.getProductById('prod-1'))!;
await barTabService.addProductToTab(tabId: tab.id, product: beerNow);
await barTabService.addProductToTab(tabId: tab.id, product: beerNow);
server.respondWith({
'catalog': [beer(price: 450)],
});
await service.sync();
// Staff tap + on Foo's line
final oldLine = (await barTabService.getTabById(tab.id))!.items.single;
await barTabService.adjustTabItemQuantity(
tabItemId: oldLine.id,
delta: 1,
);
server.respondWith({});
await service.sync();
final lines = (await barTabService.getTabById(tab.id))!.items;
expect([
for (final line in lines) (line.unitPriceInCents, line.quantity),
], unorderedEquals([(400, 2), (450, 1)]));
expect(sentLines(1).last['unitPriceInCents'], 450);
});
test(
'the products the bar screen watches change without a restart',
() async {
final emitted = <List<int>>[];
final subscription = products.watchProducts().listen(
(list) => emitted.add([for (final p in list) p.priceInCents]),
);
await pumpEventQueue();
server.respondWith({
'catalog': [beer(price: 450)],
});
await service.sync();
await pumpEventQueue();
expect(emitted.last, [450]);
await subscription.cancel();
},
);
test(
'an unchanged catalog writes nothing, so the screen does not redraw',
() async {
server.respondWith({
'catalog': [beer()],
});
await service.sync();
var emissions = 0;
final subscription = products.watchProducts().listen(
(_) => emissions++,
);
await pumpEventQueue();
await service.sync();
await service.sync();
await pumpEventQueue();
// Only the first emission: the current list
expect(emissions, 1);
await subscription.cancel();
},
);
test(
'a renamed product is shown under its new name, old drinks keep theirs',
() async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(
tabId: tab.id,
product: (await products.getProductById('prod-1'))!,
);
server.respondWith({
'catalog': [beer(name: 'Pils')],
});
await service.sync();
final shown = (await products.getProducts()).single;
expect(shown.name, 'Pils');
await barTabService.addProductToTab(tabId: tab.id, product: shown);
final names = [
for (final line in (await barTabService.getTabById(tab.id))!.items)
line.productName,
];
expect(names, unorderedEquals(['Beer', 'Pils']));
},
);
test('a product added on the website can be sold right away', () async {
server.respondWith({
'catalog': [
beer(),
{
'id': 'web-1',
'name': 'Ice Tea',
'category': 'Frisdrank',
'priceInCents': 280,
'lowStockThreshold': 5,
'archived': false,
},
],
'stock': [
{'productId': 'web-1', 'stock': 24, 'countedAt': null},
],
});
await service.sync();
final iceTea = (await products.getProductById('web-1'))!;
expect(iceTea.stockQuantity, 24);
expect(iceTea.infiniteStock, isFalse);
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: iceTea);
server.respondWith({});
await service.sync();
expect(sentLines(1).single, containsPair('productId', 'web-1'));
expect(sentLines(1).single, containsPair('unitPriceInCents', 280));
expect((await products.getProductById('web-1'))!.stockQuantity, 23);
});
test(
'an archived product leaves the bar screen but stays on open tabs',
() async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(
tabId: tab.id,
product: (await products.getProductById('prod-1'))!,
);
server.respondWith({
'catalog': [beer(archived: true)],
});
await service.sync();
expect(await products.getProducts(), isEmpty);
expect(
(await barTabService.getTabById(tab.id))!.items.single.quantity,
1,
);
// The product form can still open it, e.g. from the history
expect(await products.getProductById('prod-1'), isNotNull);
},
);
test('no warning level on the website is 0 on the tablet', () async {
server.respondWith({
'catalog': [beer(lowStockThreshold: null)],
});
await service.sync();
expect((await products.getProductById('prod-1'))!.lowStockThreshold, 0);
});
test('a price change leaves the lines of the server tabs alone', () async {
server.respondWith({
'catalog': [beer(price: 450)],
'tabs': [
{
'id': 'tab-1',
'customerName': 'Alice',
'openedAt': '2026-09-24T20:00:00.000Z',
'version': 2,
'lines': [
{
'id': 'line-1',
'productId': 'prod-1',
'productName': 'Beer',
'unitPriceInCents': 400,
'quantity': 2,
'units': [
{
'id': 'u1',
'addedAt': '2026-09-24T20:00:00.000Z',
'removedAt': null,
},
{
'id': 'u2',
'addedAt': '2026-09-24T20:01:00.000Z',
'removedAt': null,
},
],
},
],
},
],
});
await service.sync();
final tab = (await barTabService.getTabById('tab-1'))!;
expect(tab.items.single.unitPriceInCents, 400);
expect(tab.totalInCents, 800);
});
});
group('a part of the response that fails', () {
Map<String, dynamic> brokenTab() {
// Two lines claiming the same unit: the database refuses the second
Map<String, dynamic> line(String id) => {
'id': id,
'productId': 'prod-1',
'productName': 'Beer',
'unitPriceInCents': 400,
'quantity': 1,
'units': [
{
'id': 'same-unit',
'addedAt': '2026-09-24T20:00:00.000Z',
'removedAt': null,
},
],
};
return {
'id': 'tab-1',
'customerName': 'Alice',
'openedAt': '2026-09-24T20:00:00.000Z',
'version': 1,
'lines': [line('line-1'), line('line-2')],
};
}
test('does not keep prices and stock from arriving', () async {
server.respondWith({
'tabs': [brokenTab()],
'catalog': [
{
'id': 'prod-1',
'name': 'Beer',
'category': 'Drinks',
'priceInCents': 450,
'lowStockThreshold': 10,
'archived': false,
},
],
'stock': [
{'productId': 'prod-1', 'stock': 42, 'countedAt': null},
],
});
await service.sync();
await pumpEventQueue();
final beer = await (database.select(
database.products,
)..where((row) => row.id.equals('prod-1'))).getSingle();
expect(beer.priceInCents, 450);
expect(beer.stockQuantity, 42);
});
test('is reported as a failed sync naming the part', () async {
server.respondWith({
'tabs': [brokenTab()],
});
await service.sync();
await pumpEventQueue();
final failed = events.where(
(e) => e.type == RemoteSyncEventType.syncFailed,
);
expect(failed.single.message, startsWith('tabs: '));
expect(eventTypes(), isNot(contains(RemoteSyncEventType.syncCompleted)));
});
test('leaves the local tabs as they were', () async {
final tab = await barTabService.createTab(customerName: 'Bob');
server.respondWith({
'tabs': [brokenTab()],
});
await service.sync();
expect(await barTabService.getTabById(tab.id), isNotNull);
expect(await barTabService.getTabById('tab-1'), isNull);
});
test('a later good response is applied normally', () async {
server.respondWith({
'tabs': [brokenTab()],
});
await service.sync();
server.respondWith({
'tabs': [
{...brokenTab(), 'lines': <Object>[]},
],
});
await service.sync();
await pumpEventQueue();
expect(await barTabService.getTabById('tab-1'), isNotNull);
expect(eventTypes().last, RemoteSyncEventType.syncCompleted);
});
});
group('server holds the stock', () {
Future<ProductRow> beer() {
return (database.select(