// 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_image_sync.dart'; import 'package:kooltap/services/product_service.dart'; import 'package:kooltap/services/remote_sync_service.dart'; import 'package:path/path.dart' as path; 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); } /// Uploads a picture for a product, as the products page does. Future> uploadImage( String productId, List png, ) async { final request = http.MultipartRequest( 'POST', Uri.parse('$_api/venues/$venueId/stock/products/$productId/image'), ) ..headers['Authorization'] = 'Bearer $token' ..files.add( http.MultipartFile.fromBytes('file', png, filename: 'photo.png'), ); return _decode(await http.Response.fromStream(await request.send())) as Map; } /// The products page: every product with its stock and picture. Future> products() async { final body = await call('GET', '/venues/$venueId/stock'); return (body as Map)['products'] as List; } 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 ProductImageSync images; final Directory pictures; final List _events = []; _Tablet._( this.database, this.tabs, this.products, this.sync, this.images, this.pictures, ) { 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 pictures = await Directory.systemTemp.createTemp('kooltap_e2e_'); final images = ProductImageSync( database: database, apiClient: api, imagesDirectory: () async => pictures, ); final sync = RemoteSyncService( database: database, barTabService: tabs, apiClient: api, secureStorage: MemorySecureStorage(), imageSync: images, ); 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, images, pictures, ); } /// 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); } /// Syncs, then waits for the pictures it started downloading. Future syncWithPictures() async { await syncNow(); await images.downloadMissing(); } Future close() async { sync.dispose(); await database.close(); await pictures.delete(recursive: true); } } 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)), ); test( 'pictures, and products edited on a tablet', () async { final suffix = DateTime.now().millisecondsSinceEpoch; // A 40x60 red PNG: taller than wide, so fitted whole final png = base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAACgAAAA8CAIAAACb22+3AAAAOElEQVR4nO3NQQkAAAgEsEty/aMYyxgiDPZfpj0RsVgsFovFYrFYLBaLxWKxWCwWi8VisVg8j+IFPRuGCFZP0vAAAAAASUVORK5CYII=', ); bool isWebp(List bytes) => String.fromCharCodes(bytes.sublist(0, 4)) == 'RIFF' && String.fromCharCodes(bytes.sublist(8, 12)) == 'WEBP'; // 1. A picture uploaded on the website reaches both tablets, as WebP final shotName = 'E2E Shot $suffix'; final shot = await website.createProduct({ 'name': shotName, 'priceInCents': 300, 'stock': 10, }); final uploaded = await website.uploadImage( shot['productId'] as String, png, ); expect(uploaded['imageId'], matches(RegExp(r'^[0-9a-f]{64}$'))); for (final tablet in [a, b]) { await tablet.syncWithPictures(); final product = await tablet.product(shotName); expect(product.imagePath, endsWith('${uploaded['imageId']}.webp')); expect(isWebp(File(product.imagePath!).readAsBytesSync()), isTrue); } // 2. A product made on tablet A, with a photo, reaches the website and // tablet B with the server's picture final drinkName = 'E2E Tablet Drink $suffix'; final photo = File(path.join(a.pictures.path, 'photo.png')) ..writeAsBytesSync(png); await a.products.createProduct( name: drinkName, category: 'Frisdrank', stockQuantity: 8, lowStockThreshold: 2, priceInCents: 220, imagePath: photo.path, ); // The tile shows the photo at once, before any sync expect((await a.product(drinkName)).imagePath, photo.path); await a.syncWithPictures(); final onWebsite = (await website.products()).singleWhere( (p) => p['name'] == drinkName, ); expect(onWebsite, containsPair('stock', 8)); expect(onWebsite['imageId'], isNotNull); await b.syncWithPictures(); final onB = await b.product(drinkName); expect(onB.priceInCents, 220); expect(onB.imagePath, endsWith('${onWebsite['imageId']}.webp')); // Tablet A now shows the server's cropped copy as well expect( (await a.product(drinkName)).imagePath, endsWith('${onWebsite['imageId']}.webp'), ); // 3. Tablet A renames it offline while the website changes its price: // both survive final drinkId = onWebsite['productId'] as String; final renamed = 'E2E Renamed $suffix'; await a.products.updateProduct( (await a.product(drinkName)).copyWith(name: renamed), ); await website.changeProduct(drinkId, {'priceInCents': 240}); await a.syncNow(); await b.syncNow(); for (final tablet in [a, b]) { final product = await tablet.product(renamed); expect(product.priceInCents, 240); } // 4. Deleted on tablet A: archived for the website and tablet B await a.products.deleteProduct(drinkId); await a.syncNow(); await b.syncNow(); expect( (await b.products.getProducts()).where((p) => p.id == drinkId), isEmpty, ); expect( (await website.products()).singleWhere( (p) => p['productId'] == drinkId, )['archivedAt'], isNotNull, ); // Leave the venue as it was await website.changeProduct(shot['productId'] as String, { 'archived': true, }); }, skip: _skip, timeout: const Timeout(Duration(minutes: 2)), ); }