Files
kooltab/test/remote_sync_service_test.dart
T

2186 lines
66 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package: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_service.dart';
import 'package:kooltap/services/remote_sync_service.dart';
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<Map<String, dynamic>> syncRequests = [];
final List<Map<String, dynamic>> acks = [];
/// While set, `/sync` responses wait for it to complete.
Completer<void>? holdSync;
late final client = MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
if (request.url.path.endsWith('/sync')) {
syncRequests.add(body);
await holdSync?.future;
return syncResponse;
}
if (request.url.path.endsWith('/ack')) {
acks.add(body);
return http.Response('', 200);
}
return http.Response('not found', 404);
});
void respondWith(Map<String, dynamic> json) {
syncResponse = http.Response(jsonEncode(json), 200);
}
}
Map<String, dynamic> _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<RemoteSyncEvent> 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<RemoteSyncEventType> 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 = <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);
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<void> wait(int ms) =>
Future<void>.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<void>();
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<void> queueEvent(String id, DateTime createdAt) async {
await database
.into(database.tabEvents)
.insert(
TabEventsCompanion.insert(
id: id,
payload: jsonEncode({'id': id, 'type': 'line_added'}),
createdAt: createdAt,
),
);
}
Future<List<String>> outboxIds() async {
final rows = await database.select(database.tabEvents).get();
return [for (final row in rows) row.id];
}
List<String> sentIds([int request = 0]) => [
for (final event in server.syncRequests[request]['events'] as List)
event['id'] as String,
];
test('sends at most 500 events, oldest first', () async {
final start = DateTime(2026, 10, 24, 20);
// Inserted newest first, so only the timestamps give the order
for (var i = 501; i >= 1; i--) {
await queueEvent('e$i', start.add(Duration(minutes: i)));
}
await service.sync();
expect(sentIds(), [for (var i = 1; i <= 500; i++) 'e$i']);
expect(server.syncRequests.single['events'].first, {
'id': 'e1',
'type': 'line_added',
});
});
test('deletes exactly the acknowledged and rejected events', () async {
final now = DateTime.now();
for (final id in ['e1', 'e2', 'e3']) {
await queueEvent(id, now);
}
server.respondWith({
'acknowledgedEventIds': ['e1'],
'rejectedEvents': [
{'id': 'e2', 'reason': 'unknown type'},
{'reason': 'not an object'},
],
});
await service.sync();
expect(await outboxIds(), ['e3']);
});
test('a failed sync deletes nothing', () async {
await queueEvent('e1', DateTime.now());
server.syncResponse = http.Response(
jsonEncode({
'acknowledgedEventIds': ['e1'],
}),
500,
);
await service.sync();
expect(await outboxIds(), ['e1']);
});
test('an older server without event fields keeps the events', () async {
await queueEvent('e1', DateTime.now());
await service.sync();
expect(await outboxIds(), ['e1']);
});
test('events created during the sync wait for the next one', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
server.holdSync = Completer<void>();
final running = service.sync();
await pumpEventQueue();
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
server.respondWith({'acknowledgedEventIds': sentIds()});
server.holdSync!.complete();
server.holdSync = null;
await running;
// 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<String, dynamic> 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<ProductRow> product(String id) {
return (database.select(
database.products,
)..where((row) => row.id.equals(id))).getSingle();
}
Future<void> setStock(String id, int stock) async {
await (database.update(database.products)
..where((row) => row.id.equals(id)))
.write(ProductsCompanion(stockQuantity: Value(stock)));
}
Future<void> 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<Map<String, Map<String, dynamic>>> storedResults() async {
final rows = await database.select(database.stockAdjustmentResults).get();
return {
for (final row in rows)
row.id: jsonDecode(row.payload) as Map<String, dynamic>,
};
}
Future<Map<String, bool>> acknowledged() async {
final rows = await database.select(database.stockAdjustmentResults).get();
return {for (final row in rows) row.id: row.acknowledged};
}
Future<void> 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<String> 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<void>.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<String, dynamic> 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<String, dynamic> beerLine({
required String id,
required List<Map<String, dynamic>> 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<String, dynamic> serverTab(
String id,
String name, {
int version = 3,
List<Map<String, dynamic>> lines = const [],
}) {
return {
'id': id,
'customerName': name,
'openedAt': at.toIso8601String(),
'version': version,
'lines': lines,
};
}
Future<List<String>> outboxTypes() async {
final rows =
await (database.select(database.tabEvents)..orderBy([
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
]))
.get();
return [
for (final row in rows)
(jsonDecode(row.payload) as Map<String, dynamic>)['type'] as String,
];
}
List<String> sentIds([int request = 0]) => [
for (final event in server.syncRequests[request]['events'] as List)
event['id'] as String,
];
Future<int> 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<void>();
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': <Map<String, dynamic>>[]});
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<void>();
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<void>();
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<void>();
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': <Map<String, dynamic>>[]});
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<String, dynamic>,
)
.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<void>();
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<void>();
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<String, dynamic> 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 = <int>[];
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<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(
database.products,
)..where((row) => row.id.equals('prod-1'))).getSingle();
}
Future<List<Map<String, dynamic>>> outbox() async {
final rows =
await (database.select(database.tabEvents)..orderBy([
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
]))
.get();
return [
for (final row in rows) jsonDecode(row.payload) as Map<String, dynamic>,
];
}
List<String> 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<void> queue(Map<String, dynamic> 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<String, dynamic> 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<void> 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<void> wait(int ms) =>
Future<void>.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': <Map<String, dynamic>>[],
},
],
});
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));
});
});
}