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
+349
View File
@@ -245,6 +245,355 @@ void main() {
});
});
// The website changes prices: drinks already on a tab keep theirs, the
// next one is charged the new price
group('addProductToTab after a price or name change', () {
test(
'a new price starts a new line; earlier drinks keep theirs',
() async {
final tab = await service.createTab(customerName: 'John');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(priceInCents: 450),
);
final items = (await service.getTabById(tab.id))!.items;
expect([
for (final item in items) (item.unitPriceInCents, item.quantity),
], unorderedEquals([(400, 2), (450, 1)]));
expect((await service.getTabById(tab.id))!.totalInCents, 1250);
},
);
test('more drinks at the new price go on the new line', () async {
final tab = await service.createTab(customerName: 'John');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
for (var i = 0; i < 3; i++) {
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(priceInCents: 450),
);
}
final items = (await service.getTabById(tab.id))!.items;
expect(items, hasLength(2));
expect(
items.firstWhere((item) => item.unitPriceInCents == 450).quantity,
3,
);
});
test('a price going back joins the line at that price again', () async {
final tab = await service.createTab(customerName: 'John');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(priceInCents: 300),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
final items = (await service.getTabById(tab.id))!.items;
expect([
for (final item in items) (item.unitPriceInCents, item.quantity),
], unorderedEquals([(400, 2), (300, 1)]));
});
test('a renamed product starts a new line under its new name', () async {
final tab = await service.createTab(customerName: 'John');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(name: 'Pils'),
);
final items = (await service.getTabById(tab.id))!.items;
expect([
for (final item in items) item.productName,
], unorderedEquals(['Test Beer', 'Pils']));
});
test('both lines count down the stock of the one product', () async {
final tab = await service.createTab(customerName: 'John');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(priceInCents: 450),
);
final stored = await (database.select(
database.products,
)..where((row) => row.id.equals('prod-1'))).getSingle();
expect(stored.stockQuantity, 98);
});
test('settling charges each line at its own price', () async {
final tab = await service.createTab(customerName: 'John');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(priceInCents: 450),
);
await service.closeTab(tab.id);
final closed = (await service.getClosedTabs()).single;
expect(closed.totalInCents, 850);
expect([
for (final item in closed.items) item.unitPriceInCents,
], unorderedEquals([400, 450]));
});
});
// Foo has 2 at the old price; the website changes the price; staff tap +
// on Foo's line. The 2 stay at the old price, every new one is charged
// the new price.
group('+ on a line after a price change', () {
Future<void> setPrice(int priceInCents, {String? name}) async {
await (database.update(
database.products,
)..where((row) => row.id.equals('prod-1'))).write(
ProductsCompanion(
priceInCents: Value(priceInCents),
name: name == null ? const Value.absent() : Value(name),
),
);
}
Future<String> fooWithTwoAtOldPrice() async {
final tab = await service.createTab(customerName: 'Foo');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await setPrice(450);
return tab.id;
}
Future<List<(int, int)>> lines(String tabId) async {
return [
for (final item in (await service.getTabById(tabId))!.items)
(item.unitPriceInCents, item.quantity),
];
}
Future<String> lineAt(String tabId, int priceInCents) async {
return (await service.getTabById(tabId))!.items
.firstWhere((item) => item.unitPriceInCents == priceInCents)
.id;
}
test(
'the old drinks keep their price, the new one gets the new price',
() async {
final tabId = await fooWithTwoAtOldPrice();
final added = await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 1,
);
expect(added, 1);
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)]));
expect((await service.getTabById(tabId))!.totalInCents, 1250);
},
);
test(
'tapping + on the old line again keeps adding at the new price',
() async {
final tabId = await fooWithTwoAtOldPrice();
final oldLine = await lineAt(tabId, 400);
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
},
);
test('+ on the line at the new price adds there', () async {
final tabId = await fooWithTwoAtOldPrice();
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 1,
);
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 450),
delta: 1,
);
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
});
test('the tile and + end up on the same new line', () async {
final tabId = await fooWithTwoAtOldPrice();
await service.addProductToTab(
tabId: tabId,
product: createTestProduct(priceInCents: 450),
);
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 1,
);
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
});
test('+ several at once adds them all at the new price', () async {
final tabId = await fooWithTwoAtOldPrice();
final added = await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 3,
);
expect(added, 3);
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 3)]));
});
test('− on the old line takes off a drink at the old price', () async {
final tabId = await fooWithTwoAtOldPrice();
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 1,
);
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: -1,
);
expect(await lines(tabId), unorderedEquals([(400, 1), (450, 1)]));
});
test('a price going back makes + add to the old line again', () async {
final tabId = await fooWithTwoAtOldPrice();
await setPrice(400);
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 1,
);
expect(await lines(tabId), [(400, 3)]);
});
test('a renamed product gets a new line under its new name', () async {
final tab = await service.createTab(customerName: 'Foo');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await setPrice(400, name: 'Pils');
await service.adjustTabItemQuantity(
tabItemId: (await service.getTabById(tab.id))!.items.single.id,
delta: 1,
);
final names = [
for (final item in (await service.getTabById(tab.id))!.items)
(item.productName, item.quantity),
];
expect(names, unorderedEquals([('Test Beer', 1), ('Pils', 1)]));
});
test(
'a hidden (archived) product is still charged its latest price',
() async {
final tabId = await fooWithTwoAtOldPrice();
await (database.update(database.products)
..where((row) => row.id.equals('prod-1')))
.write(const ProductsCompanion(active: Value(false)));
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 1,
);
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)]));
},
);
test('a line whose product is gone keeps its own price', () async {
final tab = await service.createTab(customerName: 'Foo');
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await database.delete(database.products).go();
await service.adjustTabItemQuantity(
tabItemId: (await service.getTabById(tab.id))!.items.single.id,
delta: 1,
);
expect(await lines(tab.id), [(400, 2)]);
});
test('another tab without the product gets the new price', () async {
await fooWithTwoAtOldPrice();
final bar = await service.createTab(customerName: 'Bar');
await service.addProductToTab(
tabId: bar.id,
product: createTestProduct(priceInCents: 450),
);
expect(await lines(bar.id), [(450, 1)]);
});
test('every drink counts down the stock of the one product', () async {
final tabId = await fooWithTwoAtOldPrice();
await service.adjustTabItemQuantity(
tabItemId: await lineAt(tabId, 400),
delta: 2,
);
final stored = await (database.select(
database.products,
)..where((row) => row.id.equals('prod-1'))).getSingle();
expect(stored.stockQuantity, 96);
});
});
group('adjustTabItemQuantity', () {
test('updates item quantity', () async {
final tab = await service.createTab(customerName: 'John');
+307
View File
@@ -0,0 +1,307 @@
// End to end against a running KoolTap server: two tablets pair, the website
// adds and changes products, the tablets sell and sync over real HTTP.
//
// Skipped unless the server is given. Use a scratch database: this pairs two
// tablets and adds products to the admin's venue.
//
// KOOLTAP_E2E_API=http://localhost:3101/api/v1 \
// KOOLTAP_E2E_EMAIL=admin@example.com KOOLTAP_E2E_PASSWORD=... \
// flutter test test/e2e
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/models/remote_command.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/services/kooltap_api_client.dart';
import 'package:kooltap/services/product_service.dart';
import 'package:kooltap/services/remote_sync_service.dart';
import '../helpers/memory_secure_storage.dart';
final _api = Platform.environment['KOOLTAP_E2E_API'];
final _skip = _api == null
? 'Set KOOLTAP_E2E_API to run against a server'
: false;
/// The website, through the same API the dashboard uses.
class _Website {
final String token;
final String venueId;
_Website(this.token, this.venueId);
static Future<_Website> login() async {
final response = await http.post(
Uri.parse('$_api/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': Platform.environment['KOOLTAP_E2E_EMAIL'],
'password': Platform.environment['KOOLTAP_E2E_PASSWORD'],
}),
);
final body = _decode(response) as Map<String, dynamic>;
final user = body['user'] as Map<String, dynamic>;
return _Website(body['accessToken'] as String, user['venueId'] as String);
}
Future<Object?> call(String method, String path, [Object? body]) async {
final request = http.Request(method, Uri.parse('$_api$path'))
..headers['Authorization'] = 'Bearer $token'
..headers['Content-Type'] = 'application/json';
if (body != null) request.body = jsonEncode(body);
return _decode(await http.Response.fromStream(await request.send()));
}
Future<String> registrationCode() async {
final body = await call('GET', '/devices/generation-code');
return (body as Map<String, dynamic>)['code'] as String;
}
Future<Map<String, dynamic>> createProduct(
Map<String, Object?> product,
) async {
return await call('POST', '/venues/$venueId/stock/products', product)
as Map<String, dynamic>;
}
Future<void> changeProduct(String id, Map<String, Object?> changes) {
return call('PATCH', '/venues/$venueId/stock/products/$id', changes);
}
Future<List<dynamic>> tabs() async {
final body = await call('GET', '/venues/$venueId/tabs');
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
}
static Object? _decode(http.Response response) {
if (response.statusCode >= 300) {
throw StateError('${response.statusCode}: ${response.body}');
}
return response.body.isEmpty ? null : jsonDecode(response.body);
}
}
/// A tablet with its own database, paired like the app pairs.
class _Tablet {
final AppDatabase database;
final DriftBarTabService tabs;
final DriftProductService products;
final RemoteSyncService sync;
final List<RemoteSyncEvent> _events = [];
_Tablet._(this.database, this.tabs, this.products, this.sync) {
sync.events.listen(_events.add);
}
static Future<_Tablet> pair(_Website website, String name) async {
final database = AppDatabase(NativeDatabase.memory());
final tabs = DriftBarTabService(database: database);
final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!);
final sync = RemoteSyncService(
database: database,
barTabService: tabs,
apiClient: api,
secureStorage: MemorySecureStorage(),
);
final credentials = await api.registerDevice(
venueId: website.venueId,
deviceName: name,
registrationCode: await website.registrationCode(),
);
await sync.setCredentials(
deviceId: credentials.deviceId,
token: credentials.token,
venueId: website.venueId,
);
return _Tablet._(
database,
tabs,
DriftProductService(database: database),
sync,
);
}
/// Syncs and fails the test when the sync failed.
Future<void> syncNow() async {
_events.clear();
await sync.sync();
// Sync events are delivered asynchronously
await pumpEventQueue();
final types = _events.map((e) => e.type).toList();
expect(
types,
contains(RemoteSyncEventType.syncCompleted),
reason: '$types ${_events.map((e) => e.message)}',
);
}
/// What the bar screen shows.
Future<Product> product(String name) async {
return (await products.getProducts()).singleWhere((p) => p.name == name);
}
Future<void> close() async {
sync.dispose();
await database.close();
}
}
void main() {
late _Website website;
late _Tablet a;
late _Tablet b;
setUpAll(() async {
if (_api == null) return;
// Two tablets, each with its own database, on purpose
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
website = await _Website.login();
a = await _Tablet.pair(website, 'E2E tablet A');
b = await _Tablet.pair(website, 'E2E tablet B');
await a.syncNow();
await b.syncNow();
});
tearDownAll(() async {
if (_api == null) return;
await a.close();
await b.close();
});
// One story: the steps depend on each other and run in order
test(
'products managed on the website, two tablets selling',
() async {
final suffix = DateTime.now().millisecondsSinceEpoch;
final colaName = 'E2E Cola $suffix';
// 1. The website adds a product with its stock: both tablets get it
final cola = await website.createProduct({
'name': colaName,
'category': 'Frisdrank',
'priceInCents': 250,
'stock': 24,
'lowStockThreshold': 5,
});
await a.syncNow();
await b.syncNow();
for (final tablet in [a, b]) {
final product = await tablet.product(colaName);
expect(product.id, cola['productId']);
expect(product.priceInCents, 250);
expect(product.stockQuantity, 24);
expect(product.lowStockThreshold, 5);
}
// 2. Tablet A sells one: tablet B sees the tab and the stock
final alice = await a.tabs.createTab(customerName: 'E2E Alice $suffix');
await a.tabs.addProductToTab(
tabId: alice.id,
product: await a.product(colaName),
);
await a.syncNow();
await b.syncNow();
expect((await b.tabs.getTabById(alice.id))!.items.single.quantity, 1);
expect((await b.product(colaName)).stockQuantity, 23);
// 3. The website changes the price: both tablets show it
await website.changeProduct(cola['productId'] as String, {
'priceInCents': 270,
});
await a.syncNow();
await b.syncNow();
expect((await a.product(colaName)).priceInCents, 270);
expect((await b.product(colaName)).priceInCents, 270);
// 4. The next drink is charged the new price; the first keeps its price
await b.tabs.addProductToTab(
tabId: alice.id,
product: await b.product(colaName),
);
await b.syncNow();
await a.syncNow();
for (final tablet in [a, b]) {
final lines = (await tablet.tabs.getTabById(alice.id))!.items;
expect([
for (final line in lines) (line.unitPriceInCents, line.quantity),
], unorderedEquals([(250, 1), (270, 1)]));
expect((await tablet.product(colaName)).stockQuantity, 22);
}
// 4b. Tablet A taps + on Alice's line at the old price: that drink is
// charged the new price too, the first one keeps the old price
final oldLine = (await a.tabs.getTabById(
alice.id,
))!.items.firstWhere((line) => line.unitPriceInCents == 250);
await a.tabs.adjustTabItemQuantity(tabItemId: oldLine.id, delta: 1);
await a.syncNow();
await b.syncNow();
for (final tablet in [a, b]) {
final lines = (await tablet.tabs.getTabById(alice.id))!.items;
expect([
for (final line in lines) (line.unitPriceInCents, line.quantity),
], unorderedEquals([(250, 1), (270, 2)]));
expect((await tablet.product(colaName)).stockQuantity, 21);
}
final onWebsite = (await website.tabs()).singleWhere(
(t) => t['tabId'] == alice.id,
);
expect(onWebsite['totalInCents'], 790);
// 5. A rename reaches the tablets under the same product
final renamed = 'E2E Coca-Cola $suffix';
await website.changeProduct(cola['productId'] as String, {
'name': renamed,
});
await a.syncNow();
expect((await a.product(renamed)).id, cola['productId']);
// 6. Archived: gone from the bar screen, still on Alice's tab
await website.changeProduct(cola['productId'] as String, {
'archived': true,
});
await a.syncNow();
expect(
(await a.products.getProducts()).where(
(p) => p.id == cola['productId'],
),
isEmpty,
);
expect((await a.tabs.getTabById(alice.id))!.totalInCents, 790);
// 7. The website settles Alice: both tablets show it in the history once
await website.call('POST', '/venues/${website.venueId}/commands', {
'tabId': alice.id,
'type': 'close_tab',
'expectedVersion': onWebsite['version'],
'paymentMethod': 'cash',
});
await a.syncNow();
await b.syncNow();
await b.syncNow();
for (final tablet in [a, b]) {
expect((await tablet.tabs.getTabById(alice.id))!.items, isEmpty);
final settled = (await tablet.tabs.getClosedTabs())
.where((t) => t.originalTabId == alice.id)
.toList();
expect(settled, hasLength(1));
expect(settled.single.totalInCents, 790);
}
// 8. Restored, it is back on the bar screen with the stock it had
await website.changeProduct(cola['productId'] as String, {
'archived': false,
});
await b.syncNow();
expect((await b.product(renamed)).stockQuantity, 21);
},
skip: _skip,
timeout: const Timeout(Duration(minutes: 2)),
);
}
+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(
+69 -13
View File
@@ -99,23 +99,79 @@ void main() {
expect(row.id, event['id']);
});
test('adding to an existing line keeps that line\'s price', () async {
test(
'a drink at a new price goes on a new line, with that price',
() async {
final tab = await openTab('Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
await service.addProductToTab(
tabId: tab.id,
product: _cola.copyWith(priceInCents: 300),
);
final events = await outbox();
expect(events.map((event) => event['unitPriceInCents']), [250, 300]);
expect(events.map((event) => event['productId']), ['prod-1', 'prod-1']);
// A different line, so the server doesn't put it on the old one either
expect(events[0]['lineId'], isNot(events[1]['lineId']));
},
);
test(
'+ on a line at an old price sends the new price, on a new line',
() async {
final tab = await openTab('Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
final oldLine = await lineIdOf(tab.id);
await (database.update(database.products)
..where((row) => row.id.equals('prod-1')))
.write(const ProductsCompanion(priceInCents: Value(300)));
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
final added = (await outbox()).last;
expect(added['type'], 'line_added');
expect(added['unitPriceInCents'], 300);
expect(added['quantity'], 1);
expect(added['lineId'], isNot(oldLine));
},
);
test('a line without a product sends no productId', () async {
final tab = await openTab('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,
),
// As taken over from the tablet's own tabs: the line has no product
await database
.into(database.tabItems)
.insert(
TabItemsCompanion.insert(
id: 'seed:${tab.id}:0',
tabId: tab.id,
productId: '',
productName: 'Cola',
quantity: 1,
unitPriceInCents: 250,
createdAt: DateTime.now(),
),
);
await service.adjustTabItemQuantity(
tabItemId: 'seed:${tab.id}:0',
delta: -1,
);
final event = (await outbox()).last;
expect(event['type'], 'line_removed');
expect(event.containsKey('productId'), isFalse);
expect(event['productName'], 'Cola');
});
test('a drink at the same price goes on the same line', () async {
final tab = await openTab('Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
await service.addProductToTab(tabId: tab.id, product: _cola);
final events = await outbox();
expect(events.map((event) => event['unitPriceInCents']), [250, 250]);
expect(events[0]['lineId'], events[1]['lineId']);
});
test('changing a quantity writes the difference', () async {