407 lines
12 KiB
Dart
407 lines
12 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('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<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');
|
|
},
|
|
);
|
|
});
|
|
}
|