Files
kooltab/test/e2e/website_catalog_e2e_test.dart

460 lines
16 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_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<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);
}
/// Uploads a picture for a product, as the products page does.
Future<Map<String, dynamic>> uploadImage(
String productId,
List<int> 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<String, dynamic>;
}
/// The products page: every product with its stock and picture.
Future<List<dynamic>> products() async {
final body = await call('GET', '/venues/$venueId/stock');
return (body as Map<String, dynamic>)['products'] as List<dynamic>;
}
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 ProductImageSync images;
final Directory pictures;
final List<RemoteSyncEvent> _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<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);
}
/// Syncs, then waits for the pictures it started downloading.
Future<void> syncWithPictures() async {
await syncNow();
await images.downloadMissing();
}
Future<void> 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<int> 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)),
);
}