// 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; final user = body['user'] as Map; return _Website(body['accessToken'] as String, user['venueId'] as String); } Future 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 registrationCode() async { final body = await call('GET', '/devices/generation-code'); return (body as Map)['code'] as String; } Future> createProduct( Map product, ) async { return await call('POST', '/venues/$venueId/stock/products', product) as Map; } Future changeProduct(String id, Map changes) { return call('PATCH', '/venues/$venueId/stock/products/$id', changes); } Future> tabs() async { final body = await call('GET', '/venues/$venueId/tabs'); return (body as Map)['tabs'] as List; } 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 _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 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(String name) async { return (await products.getProducts()).singleWhere((p) => p.name == name); } Future 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)), ); }