feat: add alot
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
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(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');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user