308 lines
10 KiB
Dart
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)),
|
|
);
|
|
}
|