feat: make server own products
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user