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> syncRequests = []; final List> acks = []; /// While set, `/sync` responses wait for it to complete. Completer? holdSync; late final client = MockClient((request) async { final body = jsonDecode(request.body) as Map; 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 json) { syncResponse = http.Response(jsonEncode(json), 200); } } Map _closeCommand({ required String id, required String tabId, required int expectedVersion, String type = 'close_tab', DateTime? createdAt, }) { return { 'id': id, 'type': type, 'tabId': tabId, 'expectedVersion': expectedVersion, 'paymentMethod': 'payconiq', 'requestedBy': 'Dashboard', 'createdAt': (createdAt ?? DateTime.now()).toIso8601String(), }; } void main() { late AppDatabase database; late DriftBarTabService barTabService; late MemorySecureStorage storage; late _FakeServer server; late RemoteSyncService service; late List events; setUp(() async { database = AppDatabase(NativeDatabase.memory()); barTabService = DriftBarTabService(database: database); storage = MemorySecureStorage(); server = _FakeServer(); service = RemoteSyncService( database: database, barTabService: barTabService, apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), secureStorage: storage, ); await service.setCredentials( deviceId: 'device-1', token: 'token', venueId: 'venue-1', ); events = []; service.events.listen(events.add); await database .into(database.products) .insert( ProductsCompanion.insert( id: _beer.id, name: _beer.name, category: _beer.category, stockQuantity: _beer.stockQuantity, lowStockThreshold: _beer.lowStockThreshold, priceInCents: _beer.priceInCents, ), ); }); tearDown(() async { service.dispose(); await database.close(); }); List eventTypes() => [ for (final event in events) event.type, ]; group('sync', () { test('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('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('refuses to close a tab that changed since the request', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); server.respondWith({ 'commands': [ _closeCommand(id: 'cmd-1', tabId: tab.id, expectedVersion: 1), ], }); await service.sync(); await pumpEventQueue(); expect(await barTabService.getClosedTabs(), isEmpty); expect((await barTabService.getTabById(tab.id))!.items, hasLength(1)); expect(server.acks.single['status'], 'conflict'); expect(eventTypes(), contains(RemoteSyncEventType.conflict)); }); test('reports a conflict for an unknown tab', () async { server.respondWith({ 'commands': [ _closeCommand(id: 'cmd-1', tabId: 'missing', expectedVersion: 1), ], }); await service.sync(); expect(server.acks.single['status'], 'conflict'); }); test('expires commands older than the expiry window', () async { final tab = await barTabService.createTab(customerName: 'Alice'); server.respondWith({ 'commands': [ _closeCommand( id: 'cmd-1', tabId: tab.id, expectedVersion: tab.version, createdAt: DateTime.now().subtract(const Duration(days: 2)), ), ], }); await service.sync(); expect(server.acks.single['status'], 'expired'); }); test('fails commands of an unknown type', () async { server.respondWith({ 'commands': [ _closeCommand( id: 'cmd-1', tabId: 'tab', expectedVersion: 1, type: 'refund', ), ], }); await service.sync(); expect(server.acks.single['status'], 'failed'); }); test( 're-acks an already processed command without re-running it', () async { final tab = await barTabService.createTab(customerName: 'Alice'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); final current = (await barTabService.getTabById(tab.id))!; server.respondWith({ 'commands': [ _closeCommand( id: 'cmd-1', tabId: tab.id, expectedVersion: current.version, ), ], }); await service.sync(); await service.sync(); expect(await barTabService.getClosedTabs(), hasLength(1)); expect(server.acks.map((ack) => ack['status']), [ 'completed', 'completed', ]); final activity = await service.getRecentActivity(); expect(activity.single.result, CommandResult.completed); }, ); }); group('sync after tab changes', () { late RemoteSyncService fast; setUp(() { fast = RemoteSyncService( database: database, barTabService: barTabService, apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), secureStorage: storage, changeDebounce: const Duration(milliseconds: 50), changeMaxDelay: const Duration(milliseconds: 300), ); server.respondWith({'cursor': 'cursor-1'}); }); tearDown(() => fast.dispose()); Future wait(int ms) => Future.delayed(Duration(milliseconds: ms)); int syncedTotal() => (server.syncRequests.last['openTabs'] as List).single['totalInCents'] as int; test('syncs shortly after a tab changes', () async { final tab = await barTabService.createTab(customerName: 'Alice'); fast.startPeriodicSync(); await wait(150); expect(server.syncRequests, isEmpty, reason: 'starting is not a change'); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await wait(250); expect(server.syncRequests, hasLength(1)); expect(syncedTotal(), 400); }); test('bundles a burst of changes into one sync', () async { final tab = await barTabService.createTab(customerName: 'Alice'); fast.startPeriodicSync(); await wait(150); for (var i = 0; i < 5; i++) { await barTabService.addProductToTab(tabId: tab.id, product: _beer); } await wait(250); expect(server.syncRequests, hasLength(1)); expect(syncedTotal(), 2000); }); test('does not sync after changes once stopped', () async { final tab = await barTabService.createTab(customerName: 'Alice'); fast.startPeriodicSync(); await wait(150); fast.stopPeriodicSync(); await barTabService.addProductToTab(tabId: tab.id, product: _beer); await wait(250); expect(server.syncRequests, isEmpty); }); test( 'a sync requested during a running sync runs once afterwards', () async { final tab = await barTabService.createTab(customerName: 'Alice'); server.holdSync = Completer(); final first = fast.sync(); await wait(50); await barTabService.addProductToTab(tabId: tab.id, product: _beer); unawaited(fast.sync()); unawaited(fast.sync()); server.holdSync!.complete(); server.holdSync = null; await first; await wait(150); expect(server.syncRequests, hasLength(2)); expect(syncedTotal(), 400, reason: 'the follow-up carries the change'); }, ); }); }