Files
kooltab/test/e2e/website_catalog_e2e_test.dart
T

308 lines
10 KiB
Dart

// End to end against a running KoolTap server: two tablets pair, the website
// adds and changes products, the tablets sell and sync over real HTTP.
//
// Skipped unless the server is given. Use a scratch database: this pairs two
// tablets and adds products to the admin's venue.
//
// KOOLTAP_E2E_API=http://localhost:3101/api/v1 \
// KOOLTAP_E2E_EMAIL=admin@example.com KOOLTAP_E2E_PASSWORD=... \
// flutter test test/e2e
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
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';
final _api = Platform.environment['KOOLTAP_E2E_API'];
final _skip = _api == null
? 'Set KOOLTAP_E2E_API to run against a server'
: false;
/// The website, through the same API the dashboard uses.
class _Website {
final String token;
final String venueId;
_Website(this.token, this.venueId);
static Future<_Website> login() async {
final response = await http.post(
Uri.parse('$_api/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': Platform.environment['KOOLTAP_E2E_EMAIL'],
'password': Platform.environment['KOOLTAP_E2E_PASSWORD'],
}),
);
final body = _decode(response) as Map<String, dynamic>;
final user = body['user'] as Map<String, dynamic>;
return _Website(body['accessToken'] as String, user['venueId'] as String);
}
Future<Object?> call(String method, String path, [Object? body]) async {
final request = http.Request(method, Uri.parse('$_api$path'))
..headers['Authorization'] = 'Bearer $token'
..headers['Content-Type'] = 'application/json';
if (body != null) request.body = jsonEncode(body);
return _decode(await http.Response.fromStream(await request.send()));
}
Future<String> registrationCode() async {
final body = await call('GET', '/devices/generation-code');
return (body as Map<String, dynamic>)['code'] as String;
}
Future<Map<String, dynamic>> createProduct(
Map<String, Object?> product,
) async {
return await call('POST', '/venues/$venueId/stock/products', product)
as Map<String, dynamic>;
}
Future<void> changeProduct(String id, Map<String, Object?> changes) {
return call('PATCH', '/venues/$venueId/stock/products/$id', changes);
}
Future<List<dynamic>> tabs() async {
final body = await call('GET', '/venues/$venueId/tabs');
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
}
static Object? _decode(http.Response response) {
if (response.statusCode >= 300) {
throw StateError('${response.statusCode}: ${response.body}');
}
return response.body.isEmpty ? null : jsonDecode(response.body);
}
}
/// A tablet with its own database, paired like the app pairs.
class _Tablet {
final AppDatabase database;
final DriftBarTabService tabs;
final DriftProductService products;
final RemoteSyncService sync;
final List<RemoteSyncEvent> _events = [];
_Tablet._(this.database, this.tabs, this.products, this.sync) {
sync.events.listen(_events.add);
}
static Future<_Tablet> pair(_Website website, String name) async {
final database = AppDatabase(NativeDatabase.memory());
final tabs = DriftBarTabService(database: database);
final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!);
final sync = RemoteSyncService(
database: database,
barTabService: tabs,
apiClient: api,
secureStorage: MemorySecureStorage(),
);
final credentials = await api.registerDevice(
venueId: website.venueId,
deviceName: name,
registrationCode: await website.registrationCode(),
);
await sync.setCredentials(
deviceId: credentials.deviceId,
token: credentials.token,
venueId: website.venueId,
);
return _Tablet._(
database,
tabs,
DriftProductService(database: database),
sync,
);
}
/// Syncs and fails the test when the sync failed.
Future<void> syncNow() async {
_events.clear();
await sync.sync();
// Sync events are delivered asynchronously
await pumpEventQueue();
final types = _events.map((e) => e.type).toList();
expect(
types,
contains(RemoteSyncEventType.syncCompleted),
reason: '$types ${_events.map((e) => e.message)}',
);
}
/// What the bar screen shows.
Future<Product> product(String name) async {
return (await products.getProducts()).singleWhere((p) => p.name == name);
}
Future<void> close() async {
sync.dispose();
await database.close();
}
}
void main() {
late _Website website;
late _Tablet a;
late _Tablet b;
setUpAll(() async {
if (_api == null) return;
// Two tablets, each with its own database, on purpose
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
website = await _Website.login();
a = await _Tablet.pair(website, 'E2E tablet A');
b = await _Tablet.pair(website, 'E2E tablet B');
await a.syncNow();
await b.syncNow();
});
tearDownAll(() async {
if (_api == null) return;
await a.close();
await b.close();
});
// One story: the steps depend on each other and run in order
test(
'products managed on the website, two tablets selling',
() async {
final suffix = DateTime.now().millisecondsSinceEpoch;
final colaName = 'E2E Cola $suffix';
// 1. The website adds a product with its stock: both tablets get it
final cola = await website.createProduct({
'name': colaName,
'category': 'Frisdrank',
'priceInCents': 250,
'stock': 24,
'lowStockThreshold': 5,
});
await a.syncNow();
await b.syncNow();
for (final tablet in [a, b]) {
final product = await tablet.product(colaName);
expect(product.id, cola['productId']);
expect(product.priceInCents, 250);
expect(product.stockQuantity, 24);
expect(product.lowStockThreshold, 5);
}
// 2. Tablet A sells one: tablet B sees the tab and the stock
final alice = await a.tabs.createTab(customerName: 'E2E Alice $suffix');
await a.tabs.addProductToTab(
tabId: alice.id,
product: await a.product(colaName),
);
await a.syncNow();
await b.syncNow();
expect((await b.tabs.getTabById(alice.id))!.items.single.quantity, 1);
expect((await b.product(colaName)).stockQuantity, 23);
// 3. The website changes the price: both tablets show it
await website.changeProduct(cola['productId'] as String, {
'priceInCents': 270,
});
await a.syncNow();
await b.syncNow();
expect((await a.product(colaName)).priceInCents, 270);
expect((await b.product(colaName)).priceInCents, 270);
// 4. The next drink is charged the new price; the first keeps its price
await b.tabs.addProductToTab(
tabId: alice.id,
product: await b.product(colaName),
);
await b.syncNow();
await a.syncNow();
for (final tablet in [a, b]) {
final lines = (await tablet.tabs.getTabById(alice.id))!.items;
expect([
for (final line in lines) (line.unitPriceInCents, line.quantity),
], unorderedEquals([(250, 1), (270, 1)]));
expect((await tablet.product(colaName)).stockQuantity, 22);
}
// 4b. Tablet A taps + on Alice's line at the old price: that drink is
// charged the new price too, the first one keeps the old price
final oldLine = (await a.tabs.getTabById(
alice.id,
))!.items.firstWhere((line) => line.unitPriceInCents == 250);
await a.tabs.adjustTabItemQuantity(tabItemId: oldLine.id, delta: 1);
await a.syncNow();
await b.syncNow();
for (final tablet in [a, b]) {
final lines = (await tablet.tabs.getTabById(alice.id))!.items;
expect([
for (final line in lines) (line.unitPriceInCents, line.quantity),
], unorderedEquals([(250, 1), (270, 2)]));
expect((await tablet.product(colaName)).stockQuantity, 21);
}
final onWebsite = (await website.tabs()).singleWhere(
(t) => t['tabId'] == alice.id,
);
expect(onWebsite['totalInCents'], 790);
// 5. A rename reaches the tablets under the same product
final renamed = 'E2E Coca-Cola $suffix';
await website.changeProduct(cola['productId'] as String, {
'name': renamed,
});
await a.syncNow();
expect((await a.product(renamed)).id, cola['productId']);
// 6. Archived: gone from the bar screen, still on Alice's tab
await website.changeProduct(cola['productId'] as String, {
'archived': true,
});
await a.syncNow();
expect(
(await a.products.getProducts()).where(
(p) => p.id == cola['productId'],
),
isEmpty,
);
expect((await a.tabs.getTabById(alice.id))!.totalInCents, 790);
// 7. The website settles Alice: both tablets show it in the history once
await website.call('POST', '/venues/${website.venueId}/commands', {
'tabId': alice.id,
'type': 'close_tab',
'expectedVersion': onWebsite['version'],
'paymentMethod': 'cash',
});
await a.syncNow();
await b.syncNow();
await b.syncNow();
for (final tablet in [a, b]) {
expect((await tablet.tabs.getTabById(alice.id))!.items, isEmpty);
final settled = (await tablet.tabs.getClosedTabs())
.where((t) => t.originalTabId == alice.id)
.toList();
expect(settled, hasLength(1));
expect(settled.single.totalInCents, 790);
}
// 8. Restored, it is back on the bar screen with the stock it had
await website.changeProduct(cola['productId'] as String, {
'archived': false,
});
await b.syncNow();
expect((await b.product(renamed)).stockQuantity, 21);
},
skip: _skip,
timeout: const Timeout(Duration(minutes: 2)),
);
}