2186 lines
66 KiB
Dart
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));
|
|
});
|
|
});
|
|
}
|