import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; 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_image_sync.dart'; import 'package:kooltap/services/product_service.dart'; import 'package:kooltap/services/remote_sync_service.dart'; import 'package:path/path.dart' as path; import 'helpers/memory_secure_storage.dart'; const _baseUrl = 'https://api.test'; const _beer = Product( id: 'prod-1', name: 'Beer', category: 'Drinks', stockQuantity: 100, lowStockThreshold: 10, priceInCents: 400, ); /// A scripted server: [syncResponse] answers `/sync`, acks are recorded. class _FakeServer { http.Response syncResponse = http.Response('{}', 200); final List> syncRequests = []; final List> acks = []; /// While set, `/sync` responses wait for it to complete. Completer? holdSync; /// Pictures uploaded by terminals, by the id the server gave them. final Map uploadedImages = {}; late final client = MockClient((request) async { final path = request.url.path; if (path.endsWith('/images') && request.method == 'POST') { final imageId = 'img${uploadedImages.length + 1}'.padRight(64, '0'); uploadedImages[imageId] = request.bodyBytes.length; return http.Response(jsonEncode({'imageId': imageId}), 201); } if (path.contains('/images/')) { return http.Response.bytes([1, 2, 3], 200); } final body = jsonDecode(request.body) as Map; if (path.endsWith('/sync')) { syncRequests.add(body); await holdSync?.future; return syncResponse; } if (path.endsWith('/ack')) { acks.add(body); return http.Response('', 200); } return http.Response('not found', 404); }); void respondWith(Map json) { syncResponse = http.Response(jsonEncode(json), 200); } } Map _closeCommand({ required String id, required String tabId, required int expectedVersion, String type = 'close_tab', DateTime? createdAt, }) { return { 'id': id, 'type': type, 'tabId': tabId, 'expectedVersion': expectedVersion, 'paymentMethod': 'payconiq', 'requestedBy': 'Dashboard', 'createdAt': (createdAt ?? DateTime.now()).toIso8601String(), }; } void main() { late AppDatabase database; late DriftBarTabService barTabService; late MemorySecureStorage storage; late _FakeServer server; late RemoteSyncService service; late List events; setUp(() async { database = AppDatabase(NativeDatabase.memory()); barTabService = DriftBarTabService(database: database); storage = MemorySecureStorage(); server = _FakeServer(); service = RemoteSyncService( database: database, barTabService: barTabService, apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), secureStorage: storage, ); await service.setCredentials( deviceId: 'device-1', token: 'token', venueId: 'venue-1', ); events = []; service.events.listen(events.add); await database .into(database.products) .insert( ProductsCompanion.insert( id: _beer.id, name: _beer.name, category: _beer.category, stockQuantity: _beer.stockQuantity, lowStockThreshold: _beer.lowStockThreshold, priceInCents: _beer.priceInCents, ), ); }); tearDown(() async { service.dispose(); await database.close(); }); List eventTypes() => [ for (final event in events) event.type, ]; 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 = []; 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); server.respondWith({'cursor': 'cursor-1'}); await service.sync(); await pumpEventQueue(); final snapshot = server.syncRequests.single['openTabs'] as List; expect(snapshot.single['customerName'], 'Alice'); expect(snapshot.single['totalInCents'], 400); expect(snapshot.single['items'], [ {'productName': 'Beer', 'quantity': 1, 'unitPriceInCents': 400}, ]); expect(eventTypes(), [RemoteSyncEventType.syncCompleted]); expect(await service.lastSuccessfulSyncAt, isNotNull); await service.sync(); expect(server.syncRequests.last['cursor'], 'cursor-1'); }); test('leaves undone drinks out of the snapshot total', () 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 item = (await barTabService.getTabById(tab.id))!.items.single; await barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: -2); await service.sync(); final snapshot = (server.syncRequests.single['openTabs'] as List).single; expect(snapshot['totalInCents'], 0); expect(snapshot['itemCount'], 0); expect(snapshot['items'], isEmpty); }); test('reports a server error as a failure, not a success', () async { server.syncResponse = http.Response('oops', 500); await service.sync(); await pumpEventQueue(); expect(eventTypes(), [RemoteSyncEventType.syncFailed]); expect(await service.lastSuccessfulSyncAt, isNull); final state = await database.select(database.remoteSyncState).getSingle(); expect(state.consecutiveFailureCount, 1); }); test('reports rate limiting as a failure', () async { server.syncResponse = http.Response( '', 429, headers: {'retry-after': '120'}, ); await service.sync(); await pumpEventQueue(); expect(eventTypes(), [RemoteSyncEventType.syncFailed]); }); test('drops the token and reports when it was revoked', () async { server.syncResponse = http.Response('', 401); await service.sync(); await pumpEventQueue(); expect(eventTypes(), [RemoteSyncEventType.unauthorized]); expect(await service.isConfigured, false); }); test('creates planned tabs that are not open yet, once', () async { await barTabService.createTab(customerName: 'Alice'); server.respondWith({ 'plannedTabNames': ['Alice', 'Bob', 'Bob', ' '], }); await service.sync(); final names = (await barTabService.getOpenTabs()).map( (tab) => tab.customerName, ); expect(names, unorderedEquals(['Alice', 'Bob'])); }); }); group('close_tab commands', () { test('closes the tab when the version matches', () async { final tab = await barTabService.createTab(customerName: 'Alice'); 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, ), ], }); await service.sync(); await pumpEventQueue(); final closed = await barTabService.getClosedTabs(); expect(closed.single.customerName, 'Alice'); expect(closed.single.paymentMethod.value, 'payconiq'); expect(server.acks.single['status'], 'completed'); expect(eventTypes(), contains(RemoteSyncEventType.tabClosedRemotely)); }); test('charges what the dashboard saw when it asked', () async { final tab = await barTabService.createTab(customerName: 'Alice'); for (var i = 0; i < 3; i++) { await barTabService.addProductToTab(tabId: tab.id, product: _beer); } await service.sync(); final seen = (server.syncRequests.last['openTabs'] as List).single; server.respondWith({ 'commands': [ _closeCommand( id: 'cmd-1', tabId: tab.id, expectedVersion: seen['version'] as int, ), ], }); await service.sync(); final closed = (await barTabService.getClosedTabs()).single; expect(closed.totalInCents, seen['totalInCents']); expect(closed.totalInCents, 1200); expect((await barTabService.getTabById(tab.id))!.totalInCents, 0); }); test('refuses to close a tab that changed since the request', () 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 pumpEventQueue(); expect(await barTabService.getClosedTabs(), isEmpty); expect((await barTabService.getTabById(tab.id))!.items, hasLength(1)); expect(server.acks.single['status'], 'conflict'); expect(eventTypes(), contains(RemoteSyncEventType.conflict)); }); test('reports a conflict for an unknown tab', () async { server.respondWith({ 'commands': [ _closeCommand(id: 'cmd-1', tabId: 'missing', expectedVersion: 1), ], }); await service.sync(); expect(server.acks.single['status'], 'conflict'); }); test('expires commands older than the expiry window', () async { final tab = await barTabService.createTab(customerName: 'Alice'); server.respondWith({ 'commands': [ _closeCommand( id: 'cmd-1', tabId: tab.id, expectedVersion: tab.version, createdAt: DateTime.now().subtract(const Duration(days: 2)), ), ], }); await service.sync(); expect(server.acks.single['status'], 'expired'); }); test('fails commands of an unknown type', () async { server.respondWith({ 'commands': [ _closeCommand( id: 'cmd-1', tabId: 'tab', expectedVersion: 1, type: 'refund', ), ], }); await service.sync(); expect(server.acks.single['status'], 'failed'); }); test( 're-acks an already processed command without re-running it', () async { final tab = await barTabService.createTab(customerName: 'Alice'); 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, ), ], }); await service.sync(); await service.sync(); expect(await barTabService.getClosedTabs(), hasLength(1)); expect(server.acks.map((ack) => ack['status']), [ 'completed', 'completed', ]); final activity = await service.getRecentActivity(); expect(activity.single.result, CommandResult.completed); }, ); }); group('sync after tab changes', () { late RemoteSyncService fast; setUp(() { fast = RemoteSyncService( database: database, barTabService: barTabService, apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), secureStorage: storage, changeDebounce: const Duration(milliseconds: 50), changeMaxDelay: const Duration(milliseconds: 300), ); server.respondWith({'cursor': 'cursor-1'}); }); tearDown(() => fast.dispose()); Future wait(int ms) => Future.delayed(Duration(milliseconds: ms)); int syncedTotal() => (server.syncRequests.last['openTabs'] as List).single['totalInCents'] as int; test('syncs shortly after a tab changes', () async { final tab = await barTabService.createTab(customerName: 'Alice'); fast.startPeriodicSync(); await wait(150); expect(server.syncRequests, isEmpty, reason: 'starting is not a change'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await wait(250); expect(server.syncRequests, hasLength(1)); expect(syncedTotal(), 400); }); test('bundles a burst of changes into one sync', () async { final tab = await barTabService.createTab(customerName: 'Alice'); fast.startPeriodicSync(); await wait(150); for (var i = 0; i < 5; i++) { await barTabService.addProductToTab(tabId: tab.id, product: _beer); } await wait(250); expect(server.syncRequests, hasLength(1)); expect(syncedTotal(), 2000); }); test('does not sync after changes once stopped', () async { final tab = await barTabService.createTab(customerName: 'Alice'); fast.startPeriodicSync(); await wait(150); fast.stopPeriodicSync(); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await wait(250); expect(server.syncRequests, isEmpty); }); test( 'a sync requested during a running sync runs once afterwards', () async { final tab = await barTabService.createTab(customerName: 'Alice'); server.holdSync = Completer(); final first = fast.sync(); await wait(50); await barTabService.addProductToTab(tabId: tab.id, product: _beer); unawaited(fast.sync()); unawaited(fast.sync()); server.holdSync!.complete(); server.holdSync = null; await first; await wait(150); expect(server.syncRequests, hasLength(2)); expect(syncedTotal(), 400, reason: 'the follow-up carries the change'); }, ); }); group('tab events', () { Future 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> outboxIds() async { final rows = await database.select(database.tabEvents).get(); return [for (final row in rows) row.id]; } List 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(); 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; // tab_opened and the first line_added expect(sentIds(), hasLength(2)); final remaining = await outboxIds(); expect(remaining, hasLength(1)); expect(sentIds(), isNot(contains(remaining.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, ['tab_opened', 'line_added']); }); }); group('stock', () { Map adjustment({ required String id, String productId = 'prod-1', String type = 'add', required int quantity, }) { return { 'id': id, 'productId': productId, 'type': type, 'quantity': quantity, 'note': null, 'requestedBy': 'bram@example.com', 'createdAt': '2026-10-24T15:02:11.000Z', }; } Future product(String id) { return (database.select( database.products, )..where((row) => row.id.equals(id))).getSingle(); } Future setStock(String id, int stock) async { await (database.update(database.products) ..where((row) => row.id.equals(id))) .write(ProductsCompanion(stockQuantity: Value(stock))); } Future insertProduct( String id, { bool infiniteStock = false, bool active = true, String category = 'Drinks', }) async { await database .into(database.products) .insert( ProductsCompanion.insert( id: id, name: id, category: category, stockQuantity: 0, lowStockThreshold: 0, priceInCents: 200, infiniteStock: Value(infiniteStock), active: Value(active), ), ); } Future>> storedResults() async { final rows = await database.select(database.stockAdjustmentResults).get(); return { for (final row in rows) row.id: jsonDecode(row.payload) as Map, }; } Future> acknowledged() async { final rows = await database.select(database.stockAdjustmentResults).get(); return {for (final row in rows) row.id: row.acknowledged}; } Future queueResult( String id, DateTime createdAt, { bool acknowledged = false, }) async { await database .into(database.stockAdjustmentResults) .insert( StockAdjustmentResultsCompanion.insert( id: id, payload: jsonEncode({'id': id, 'status': 'applied'}), createdAt: createdAt, acknowledged: Value(acknowledged), ), ); } List sentResultIds([int request = 0]) => [ for (final result in server.syncRequests[request]['stockAdjustmentResults'] as List) result['id'] as String, ]; test('sends every product, with a null stock when untracked', () async { await insertProduct('water', infiniteStock: true, category: ' '); await insertProduct('old', active: false); await service.sync(); final products = server.syncRequests.single['products'] as List; expect(products, [ { 'id': 'prod-1', 'name': 'Beer', 'category': 'Drinks', 'priceInCents': 400, 'stock': 100, }, { 'id': 'water', 'name': 'water', 'category': null, 'priceInCents': 200, 'stock': null, }, { 'id': 'old', 'name': 'old', 'category': 'Drinks', 'priceInCents': 200, 'stock': 0, }, ]); expect((products[1] as Map).containsKey('stock'), isTrue); }); test('the catalog stock matches the events that are sent', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await service.sync(); final request = server.syncRequests.single; expect((request['events'] as List), hasLength(3)); expect((request['products'] as List).single['stock'], 98); }); test('adds deliveries and takes stock out', () async { await setStock('prod-1', 48); server.respondWith({ 'stockAdjustments': [ adjustment(id: 'a1', quantity: 24), adjustment(id: 'a2', quantity: -30), ], }); await service.sync(); expect((await product('prod-1')).stockQuantity, 42); expect(await storedResults(), { 'a1': {'id': 'a1', 'status': 'applied', 'stockAfter': 72}, 'a2': {'id': 'a2', 'status': 'applied', 'stockAfter': 42}, }); }); test('a stock count starts tracking an untracked product', () async { await insertProduct('water', infiniteStock: true); server.respondWith({ 'stockAdjustments': [ adjustment(id: 'a1', productId: 'water', type: 'set', quantity: 30), ], }); await service.sync(); final water = await product('water'); expect(water.infiniteStock, isFalse); expect(water.stockQuantity, 30); expect((await storedResults())['a1'], { 'id': 'a1', 'status': 'applied', 'stockAfter': 30, }); }); test('refuses to add to an untracked product', () async { await insertProduct('water', infiniteStock: true); server.respondWith({ 'stockAdjustments': [ adjustment(id: 'a1', productId: 'water', quantity: 24), ], }); await service.sync(); final water = await product('water'); expect(water.infiniteStock, isTrue); expect(water.stockQuantity, 0); expect((await storedResults())['a1'], { 'id': 'a1', 'status': 'rejected', 'reason': 'stock_not_tracked', }); }); test('refuses an unknown product', () async { server.respondWith({ 'stockAdjustments': [ adjustment(id: 'a1', productId: 'nope', quantity: 5), ], }); await service.sync(); expect((await storedResults())['a1'], { 'id': 'a1', 'status': 'rejected', 'reason': 'unknown_product', }); }); test('ignores an unknown type and a malformed adjustment', () async { server.respondWith({ 'stockAdjustments': [ adjustment(id: 'a1', type: 'multiply', quantity: 2), {'id': 'a2'}, adjustment(id: 'a3', quantity: 1), ], }); await service.sync(); expect((await storedResults()).keys, ['a3']); expect((await product('prod-1')).stockQuantity, 101); }); test('applies an adjustment once, however often it arrives', () async { server.respondWith({ 'stockAdjustments': [ adjustment(id: 'a1', quantity: 24), adjustment(id: 'a1', quantity: 24), ], }); await service.sync(); // Still in a response computed before the server saw the outcome server.respondWith({ 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], 'acknowledgedStockAdjustmentResultIds': ['a1'], }); await service.sync(); await service.sync(); expect((await product('prod-1')).stockQuantity, 124); expect(sentResultIds(1), ['a1']); expect(sentResultIds(2), isEmpty); }); test('sends the result and the new stock in the next sync', () async { server.respondWith({ 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], }); await service.sync(); await service.sync(); final request = server.syncRequests[1]; expect(request['stockAdjustmentResults'], [ {'id': 'a1', 'status': 'applied', 'stockAfter': 124}, ]); expect((request['products'] as List).single['stock'], 124); }); test('sends at most 500 unacknowledged results, oldest first', () async { final start = DateTime(2026, 10, 24, 20); await queueResult('done', start, acknowledged: true); for (var i = 501; i >= 1; i--) { await queueResult('r$i', start.add(Duration(minutes: i))); } await service.sync(); expect(sentResultIds(), [for (var i = 1; i <= 500; i++) 'r$i']); }); test('marks exactly the acknowledged and rejected results', () async { final now = DateTime.now(); for (final id in ['r1', 'r2', 'r3']) { await queueResult(id, now); } server.respondWith({ 'acknowledgedStockAdjustmentResultIds': ['r1'], 'rejectedStockAdjustmentResults': [ {'id': 'r2', 'reason': 'bad payload'}, {'reason': 'not an object'}, ], }); await service.sync(); expect(await acknowledged(), {'r1': true, 'r2': true, 'r3': false}); }); test('a failed sync changes nothing', () async { await queueResult('r1', DateTime.now()); server.syncResponse = http.Response( jsonEncode({ 'acknowledgedStockAdjustmentResultIds': ['r1'], 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], }), 500, ); await service.sync(); expect(await acknowledged(), {'r1': false}); expect((await product('prod-1')).stockQuantity, 100); }); test('forgets acknowledged results after 30 days', () async { final now = DateTime(2026, 10, 24); final old = now.subtract(const Duration(days: 31)); await queueResult('old-acked', old, acknowledged: true); await queueResult('old-pending', old); await queueResult( 'recent-acked', now.subtract(const Duration(days: 29)), acknowledged: true, ); await service.pruneStockAdjustmentResults(now: now); expect((await acknowledged()).keys.toSet(), { 'old-pending', 'recent-acked', }); }); test('syncs once more right after applying an adjustment', () async { server.respondWith({ 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], }); service.startPeriodicSync(); await service.sync(); await Future.delayed(const Duration(milliseconds: 200)); expect(server.syncRequests, hasLength(2)); expect(sentResultIds(1), ['a1']); }); }); group('server holds the tabs', () { final at = DateTime.utc(2026, 10, 24, 20); Map unit( String id, { int minutes = 0, bool removed = false, }) { return { 'id': id, 'addedAt': at.add(Duration(minutes: minutes)).toIso8601String(), 'removedAt': removed ? at.add(Duration(minutes: minutes + 1)).toIso8601String() : null, }; } Map beerLine({ required String id, required List> units, int price = 400, }) { return { 'id': id, 'productId': 'prod-1', 'productName': 'Beer', 'unitPriceInCents': price, 'quantity': units.where((unit) => unit['removedAt'] == null).length, 'units': units, }; } Map serverTab( String id, String name, { int version = 3, List> lines = const [], }) { return { 'id': id, 'customerName': name, 'openedAt': at.toIso8601String(), 'version': version, 'lines': lines, }; } Future> outboxTypes() 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)['type'] as String, ]; } List sentIds([int request = 0]) => [ for (final event in server.syncRequests[request]['events'] as List) event['id'] as String, ]; Future beerStock() async { final row = await (database.select( database.products, )..where((row) => row.id.equals('prod-1'))).getSingle(); return row.stockQuantity; } test('a newly paired tablet shows the venue\'s open tabs', () async { server.respondWith({ 'tabs': [ serverTab( 'tab-1', 'Alice', lines: [ beerLine( id: 'line-1', units: [unit('u1'), unit('u2', minutes: 5, removed: true)], ), ], ), serverTab('tab-2', 'Bob'), ], }); await service.sync(); final tabs = await barTabService.getOpenTabs(includeRemoved: true); expect(tabs.map((tab) => tab.customerName).toSet(), {'Alice', 'Bob'}); final alice = tabs.firstWhere((tab) => tab.id == 'tab-1'); expect(alice.version, 3); expect(alice.totalInCents, 400); final line = alice.items.single; expect(line.id, 'line-1'); expect(line.quantity, 1); expect(line.purchases.map((purchase) => purchase.id), ['u1', 'u2']); expect(line.purchases.last.isRemoved, isTrue); expect(await beerStock(), 100, reason: 'the server state is no sale'); }); test('replaces local tabs the server has applied', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); server.holdSync = Completer(); final running = service.sync(); await pumpEventQueue(); // Settled elsewhere, and someone opened Bob server.respondWith({ 'acknowledgedEventIds': sentIds(), 'tabs': [serverTab(tab.id, 'Alice'), serverTab('tab-2', 'Bob')], }); server.holdSync!.complete(); server.holdSync = null; await running; final tabs = await barTabService.getOpenTabs(); expect(tabs.map((tab) => tab.customerName).toSet(), {'Alice', 'Bob'}); final aliceId = tab.id; expect(tabs.firstWhere((open) => open.id == aliceId).items, isEmpty); expect(await outboxTypes(), isEmpty); }); test('keeps changes the server has not applied yet', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); server.respondWith({'tabs': >[]}); await service.sync(); final alice = (await barTabService.getTabById(tab.id))!; expect(alice.customerName, 'Alice'); expect(alice.items.single.quantity, 1); expect(await outboxTypes(), ['tab_opened', 'line_added']); expect(await beerStock(), 99, reason: 'a replay is no second sale'); }); test('keeps changes made while the sync was in flight', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); final lineId = (await barTabService.getTabById(tab.id))!.items.single.id; server.holdSync = Completer(); final running = service.sync(); await pumpEventQueue(); await barTabService.addProductToTab(tabId: tab.id, product: _beer); server.respondWith({ 'acknowledgedEventIds': sentIds(), 'tabs': [ serverTab( tab.id, 'Alice', lines: [ beerLine(id: lineId, units: [unit('u1')]), ], ), ], }); server.holdSync!.complete(); server.holdSync = null; await running; final line = (await barTabService.getTabById(tab.id))!.items.single; expect(line.id, lineId); expect(line.quantity, 2); expect(await outboxTypes(), ['line_added']); }); test('drops changes the server rejected', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); server.holdSync = Completer(); final running = service.sync(); await pumpEventQueue(); final [opened, added] = sentIds(); server.respondWith({ 'acknowledgedEventIds': [opened], 'rejectedEvents': [ {'id': added, 'reason': 'invalid'}, ], 'tabs': [serverTab(tab.id, 'Alice')], }); server.holdSync!.complete(); server.holdSync = null; await running; expect((await barTabService.getTabById(tab.id))!.items, isEmpty); }); test( 'a drink put on a tab deleted elsewhere brings the tab back', () async { final tab = await barTabService.createTab(customerName: 'Alice'); server.holdSync = Completer(); final running = service.sync(); await pumpEventQueue(); server.respondWith({'acknowledgedEventIds': sentIds()}); server.holdSync!.complete(); server.holdSync = null; await running; // Deleted on another tablet while this one adds a beer await barTabService.addProductToTab(tabId: tab.id, product: _beer); server.respondWith({'tabs': >[]}); await service.sync(); final alice = (await barTabService.getTabById(tab.id))!; expect(alice.customerName, 'Alice'); expect(alice.items.single.quantity, 1); }, ); test( 'a local settlement leaves drinks added elsewhere on the tab', () 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 lineId = (await barTabService.getTabById( tab.id, ))!.items.single.id; await barTabService.closeTab(tab.id); final settledId = (await database.select(database.tabEvents).get()) .map( (row) => jsonDecode(row.payload) as Map, ) .singleWhere((event) => event['type'] == 'settled')['id'] as String; // The server hasn't seen the settlement yet, and another tablet added // a third beer server.holdSync = Completer(); final running = service.sync(); await pumpEventQueue(); server.respondWith({ 'acknowledgedEventIds': [ for (final id in sentIds()) if (id != settledId) id, ], 'tabs': [ serverTab( tab.id, 'Alice', lines: [ beerLine( id: lineId, units: [unit('u1'), unit('u2'), unit('u3', minutes: 10)], ), ], ), ], }); server.holdSync!.complete(); server.holdSync = null; await running; final line = (await barTabService.getTabById(tab.id))!.items.single; expect(line.quantity, 1); expect(line.purchases.single.id, 'u3'); }, ); test('a second line of one product still takes new drinks', () async { server.respondWith({ 'tabs': [ serverTab( 'tab-1', 'Alice', lines: [ beerLine(id: 'old-price', units: [unit('u1')]), beerLine( id: 'new-price', price: 450, units: [unit('u2', minutes: 5)], ), ], ), ], }); await service.sync(); await barTabService.addProductToTab(tabId: 'tab-1', product: _beer); final items = (await barTabService.getTabById('tab-1'))!.items; expect( {for (final item in items) item.id: item.quantity}, {'old-price': 2, 'new-price': 1}, ); }); test('works offline and catches up when the server answers', () async { server.syncResponse = http.Response('down', 503); final tab = await barTabService.createTab(customerName: 'Alice'); await service.sync(); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await service.sync(); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await service.sync(); expect( (await barTabService.getTabById(tab.id))!.items.single.quantity, 2, ); final lineId = (await barTabService.getTabById(tab.id))!.items.single.id; server.holdSync = Completer(); final running = service.sync(); await pumpEventQueue(); expect(sentIds(3), hasLength(3), reason: 'every queued change is sent'); server.respondWith({ 'acknowledgedEventIds': sentIds(3), 'tabs': [ serverTab( tab.id, 'Alice', lines: [ beerLine(id: lineId, units: [unit('u1'), unit('u2')]), ], ), ], }); server.holdSync!.complete(); server.holdSync = null; await running; expect( (await barTabService.getTabById(tab.id))!.items.single.quantity, 2, ); expect(await outboxTypes(), isEmpty); }); test('a failed sync leaves the tabs alone', () async { final tab = await barTabService.createTab(customerName: 'Alice'); server.syncResponse = http.Response( jsonEncode({ 'tabs': [serverTab('tab-2', 'Bob')], }), 500, ); await service.sync(); final tabs = await barTabService.getOpenTabs(); expect(tabs.single.id, tab.id); }); test('planned tabs come from the server, not from each tablet', () async { server.respondWith({ 'tabs': [serverTab('tab-1', 'Party')], 'plannedTabNames': ['Party', 'Other'], }); await service.sync(); final tabs = await barTabService.getOpenTabs(); expect(tabs.single.customerName, 'Party'); expect(await outboxTypes(), isEmpty); }); test('adds settlements from elsewhere to the history once', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await barTabService.closeTab(tab.id); final ownId = (await barTabService.getClosedTabs()).single.id; Map settlement(String id, String name) => { 'id': id, 'tabId': 'tab-x', 'customerName': name, 'settledAt': at.toIso8601String(), 'paymentMethod': 'payconiq', 'lines': [ beerLine(id: 'l', units: [unit('$id-1'), unit('$id-2')]), ], }; server.respondWith({ 'settlements': [settlement(ownId, 'Alice'), settlement('s-2', 'Bob')], 'settlementsCursor': 'feed-1', }); await service.sync(); await service.sync(); final closed = await barTabService.getClosedTabs(); expect(closed, hasLength(2)); final bob = closed.firstWhere((tab) => tab.id == 's-2'); expect(bob.customerName, 'Bob'); expect(bob.paymentMethod.value, 'payconiq'); expect(bob.totalInCents, 800); expect( closed.firstWhere((tab) => tab.id == ownId).totalInCents, 400, reason: 'this tablet\'s own settlement is not added again', ); expect(server.syncRequests.first.containsKey('settlementsCursor'), false); expect(server.syncRequests.last['settlementsCursor'], 'feed-1'); }); test('an unchanged server state is not shown again', () async { final emissions = []; final subscription = barTabService.watchOpenTabs().listen( (tabs) => emissions.add(tabs.length), ); server.respondWith({ 'tabs': [ serverTab( 'tab-1', 'Alice', lines: [ beerLine(id: 'line-1', units: [unit('u1')]), ], ), ], }); await service.sync(); await pumpEventQueue(); await service.sync(); await service.sync(); await pumpEventQueue(); await subscription.cancel(); expect(emissions, [0, 1]); }); }); group('website manages the catalog', () { Map 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> 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() .having((p) => p.priceInCents, 'price', 450) .having((p) => p.category, 'category', '') .having((p) => p.lowStockThreshold, 'threshold', 6) // The website has no picture: the old local one is dropped .having((p) => p.imagePath, 'image', isNull) .having((p) => p.active, 'active', isTrue), ); // New, and its stock arrives in the same sync expect( products['cola'], isA() .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() .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 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> sentLines(int request) => [ for (final event in server.syncRequests[request]['events'] as List) if (event['type'] == 'line_added') event as Map, ]; 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 = >[]; 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('products edited on this tablet', () { late DriftProductService products; setUp(() { products = DriftProductService(database: database); }); Map beer({ String name = 'Beer', int price = 400, bool archived = false, String? imageId, }) { return { 'id': 'prod-1', 'name': name, 'category': 'Drinks', 'priceInCents': price, 'lowStockThreshold': 10, 'archived': archived, 'imageId': imageId, }; } Future row(String id) => (database.select( database.products, )..where((row) => row.id.equals(id))).getSingleOrNull(); test('an offline rename stays until the server has it', () async { await products.updateProduct(_beer.copyWith(name: 'Pils')); // The server hasn't seen the rename yet: its catalog still says Beer server.respondWith({ 'catalog': [beer()], }); await service.sync(); expect((await row('prod-1'))!.name, 'Pils'); // Once acknowledged, the server's catalog has it server.respondWith({ 'acknowledgedEventIds': [ for (final event in server.syncRequests.last['events'] as List) event['id'], ], 'catalog': [beer(name: 'Pils')], }); await service.sync(); expect((await row('prod-1'))!.name, 'Pils'); }); test('a product created offline stays on the bar screen', () async { await products.createProduct( name: 'Ice Coffee', category: 'Koffie', stockQuantity: 12, lowStockThreshold: 3, priceInCents: 350, imagePath: null, ); server.respondWith({ 'catalog': [beer()], }); await service.sync(); expect( (await products.getProducts()).map((p) => p.name), containsAll(['Beer', 'Ice Coffee']), ); }); test('a product deleted offline stays hidden', () async { await products.deleteProduct('prod-1'); server.respondWith({ 'catalog': [beer()], }); await service.sync(); expect(await products.getProducts(), isEmpty); }); test( 'a website change of another field is kept next to the offline one', () async { await products.updateProduct(_beer.copyWith(name: 'Pils')); server.respondWith({ 'catalog': [beer(price: 450)], }); await service.sync(); final pils = (await row('prod-1'))!; expect(pils.name, 'Pils'); expect(pils.priceInCents, 450); }, ); test('a changed picture keeps the old one until downloaded', () async { await (database.update( database.products, )..where((row) => row.id.equals('prod-1'))).write( ProductsCompanion( imageId: Value('o' * 64), imagePath: const Value('/pictures/old.webp'), ), ); server.respondWith({ 'catalog': [beer(imageId: 'n' * 64)], }); await service.sync(); final row1 = (await row('prod-1'))!; expect(row1.imageId, 'n' * 64); expect(row1.imagePath, '/pictures/old.webp'); }); test( 'a picture waiting for upload is kept, also when the website has none', () async { await products.updateProduct( _beer.copyWith(imagePath: '/pictures/taken.jpg'), ); server.respondWith({ 'catalog': [beer()], }); await service.sync(); expect((await row('prod-1'))!.imagePath, '/pictures/taken.jpg'); }, ); }); group('pictures through the sync', () { late Directory directory; late RemoteSyncService withPictures; setUp(() async { directory = await Directory.systemTemp.createTemp('kooltap_sync_images_'); final api = KoolTapApiClient(client: server.client, baseUrl: _baseUrl); withPictures = RemoteSyncService( database: database, barTabService: barTabService, apiClient: api, secureStorage: storage, imageSync: ProductImageSync( database: database, apiClient: api, imagesDirectory: () async => directory, ), ); }); tearDown(() async { withPictures.dispose(); await directory.delete(recursive: true); }); test( 'a picture taken here is uploaded and linked in the same sync', () async { final taken = File(path.join(directory.path, 'taken.jpg')); await taken.writeAsBytes([9, 9, 9]); await DriftProductService( database: database, ).updateProduct(_beer.copyWith(imagePath: taken.path)); await withPictures.sync(); final imageId = server.uploadedImages.keys.single; expect( server.syncRequests.single['events'], contains( allOf( containsPair('type', 'product_updated'), containsPair('product', {'imageId': imageId}), ), ), ); }, ); test("the server's picture is downloaded after the sync", () async { final imageId = 'f' * 64; server.respondWith({ 'catalog': [ { 'id': 'prod-1', 'name': 'Beer', 'category': 'Drinks', 'priceInCents': 400, 'lowStockThreshold': 10, 'archived': false, 'imageId': imageId, }, ], }); await withPictures.sync(); // The download runs in the background for (var i = 0; i < 50; i++) { final row = await (database.select( database.products, )..where((row) => row.id.equals('prod-1'))).getSingle(); if (row.imagePath != null) break; await Future.delayed(const Duration(milliseconds: 20)); } final beer = await (database.select( database.products, )..where((row) => row.id.equals('prod-1'))).getSingle(); expect(beer.imagePath, path.join(directory.path, '$imageId.webp')); expect(File(beer.imagePath!).readAsBytesSync(), [1, 2, 3]); }); }); group('a part of the response that fails', () { Map brokenTab() { // Two lines claiming the same unit: the database refuses the second Map 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': []}, ], }); await service.sync(); await pumpEventQueue(); expect(await barTabService.getTabById('tab-1'), isNotNull); expect(eventTypes().last, RemoteSyncEventType.syncCompleted); }); }); group('server holds the stock', () { Future beer() { return (database.select( database.products, )..where((row) => row.id.equals('prod-1'))).getSingle(); } Future>> 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, ]; } List sentIds([int request = 0]) => [ for (final event in server.syncRequests[request]['events'] as List) event['id'] as String, ]; /// Queues an outbox event that happened at [occurredAt]. Future queue(Map payload, DateTime occurredAt) { final id = 'event-${payload['type']}-${occurredAt.toIso8601String()}'; return database .into(database.tabEvents) .insert( TabEventsCompanion.insert( id: id, payload: jsonEncode({ 'id': id, ...payload, 'occurredAt': occurredAt.toUtc().toIso8601String(), }), createdAt: occurredAt, ), ); } Map stock( String productId, int? stock, { DateTime? countedAt, }) { return { 'productId': productId, 'stock': stock, 'countedAt': countedAt?.toUtc().toIso8601String(), }; } const beerLine = { 'tabId': 'tab-1', 'customerName': 'Alice', 'lineId': 'line-1', 'productId': 'prod-1', 'productName': 'Beer', 'unitPriceInCents': 400, 'quantity': 1, }; Future insertProduct(String id, int stock) async { await database .into(database.products) .insert( ProductsCompanion.insert( id: id, name: id, category: 'Drinks', stockQuantity: stock, lowStockThreshold: 0, priceInCents: 200, ), ); } test("takes the server's stock for the products it knows", () async { await insertProduct('water', 12); await insertProduct('new', 7); server.respondWith({ 'stock': [ stock('prod-1', 46), stock('water', null), stock('unknown', 3), {'productId': 'broken', 'stock': 'many'}, ], }); await service.sync(); final products = { for (final row in await database.select(database.products).get()) row.id: (row.stockQuantity, row.infiniteStock), }; expect(products, { 'prod-1': (46, false), // Keeps its last count for when tracking resumes 'water': (12, true), 'new': (7, false), }); }); test('acknowledged sales are not counted twice', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await barTabService.addProductToTab(tabId: tab.id, product: _beer); expect((await beer()).stockQuantity, 98); server.holdSync = Completer(); final sync = service.sync(); await pumpEventQueue(); // Sold while the sync is in flight: not in this request await barTabService.addProductToTab(tabId: tab.id, product: _beer); // Another tablet sold 3 more server.respondWith({ 'acknowledgedEventIds': sentIds(), 'stock': [stock('prod-1', 95)], }); server.holdSync!.complete(); await sync; expect((await beer()).stockQuantity, 94); expect(await outbox(), hasLength(1)); server.holdSync = null; server.respondWith({ 'acknowledgedEventIds': [(await outbox()).single['id']], 'stock': [stock('prod-1', 94)], }); await service.sync(); expect((await beer()).stockQuantity, 94); }); test('a rejected sale gives its stock back', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); final [opened, sale] = await outbox(); server.respondWith({ 'acknowledgedEventIds': [opened['id']], 'rejectedEvents': [ {'id': sale['id'], 'reason': 'invalid'}, ], 'stock': [stock('prod-1', 100)], }); await service.sync(); expect((await beer()).stockQuantity, 100); }); test( "a count on the tablet replaces the server's stock until it is sent", () async { final products = DriftProductService(database: database); final tab = await barTabService.createTab(customerName: 'Alice'); await products.updateProduct(_beer.copyWith(stockQuantity: 40)); await barTabService.addProductToTab(tabId: tab.id, product: _beer); server.respondWith({ 'stock': [stock('prod-1', 100)], }); await service.sync(); expect((await beer()).stockQuantity, 39); expect( server.syncRequests.single['events'], contains(containsPair('type', 'stock_set')), ); }, ); test('a count on the tablet can stop tracking a product', () async { final products = DriftProductService(database: database); await products.updateProduct(_beer.copyWith(infiniteStock: true)); server.respondWith({ 'stock': [stock('prod-1', 100)], }); await service.sync(); expect((await beer()).infiniteStock, isTrue); }); test("sales and counts from before the server's count are in it", () async { await queue({ 'type': 'line_added', ...beerLine, 'quantity': 2, }, DateTime.utc(2026, 10, 24, 14)); await queue({ 'type': 'stock_set', 'productId': 'prod-1', 'quantity': 70, }, DateTime.utc(2026, 10, 24, 14, 30)); await queue({ 'type': 'line_added', ...beerLine, 'quantity': 3, }, DateTime.utc(2026, 10, 24, 15, 30)); server.respondWith({ 'stock': [ stock('prod-1', 30, countedAt: DateTime.utc(2026, 10, 24, 15)), ], }); await service.sync(); expect((await beer()).stockQuantity, 27); }); test( 'an untracked product ignores sales until a count tracks it', () async { await queue({ 'type': 'line_added', ...beerLine, }, DateTime.utc(2026, 10, 24, 14)); await queue({ 'type': 'stock_set', 'productId': 'prod-1', 'quantity': 10, }, DateTime.utc(2026, 10, 24, 15)); await queue({ 'type': 'line_removed', ...beerLine, }, DateTime.utc(2026, 10, 24, 16)); server.respondWith({ 'stock': [stock('prod-1', null)], }); await service.sync(); final row = await beer(); expect(row.infiniteStock, isFalse); expect(row.stockQuantity, 11); }, ); test('dashboard adjustments are left to the server', () async { server.respondWith({ 'stockAdjustments': [ { 'id': '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c', 'productId': 'prod-1', 'type': 'add', 'quantity': 24, 'note': 'Delivery', 'requestedBy': 'bram@example.com', 'createdAt': '2026-10-24T15:02:11.000Z', }, ], 'stock': [stock('prod-1', 124)], }); await service.sync(); await service.sync(); expect((await beer()).stockQuantity, 124); expect( await database.select(database.stockAdjustmentResults).get(), isEmpty, ); }); }); group('sync trigger with server tabs', () { late RemoteSyncService fast; setUp(() { fast = RemoteSyncService( database: database, barTabService: barTabService, apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), secureStorage: storage, changeDebounce: const Duration(milliseconds: 50), changeMaxDelay: const Duration(milliseconds: 300), ); }); tearDown(() => fast.dispose()); Future wait(int ms) => Future.delayed(Duration(milliseconds: ms)); test('writing the server state does not sync again', () async { fast.startPeriodicSync(); server.respondWith({ 'tabs': [ { 'id': 'tab-1', 'customerName': 'Bob', 'openedAt': '2026-10-24T20:00:00.000Z', 'version': 1, 'lines': >[], }, ], }); await fast.sync(); await wait(250); expect(server.syncRequests, hasLength(1)); expect((await barTabService.getOpenTabs()).single.customerName, 'Bob'); }); test('a local change syncs shortly after', () async { fast.startPeriodicSync(); await wait(100); await barTabService.createTab(customerName: 'Alice'); await wait(250); expect(server.syncRequests, hasLength(1)); }); }); }