Files
kooltab/test/remote_sync_service_test.dart
T
2026-10-19 21:41:34 +02:00

447 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
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/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('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');
},
);
});
}