feat: make server own products
This commit is contained in:
@@ -245,6 +245,355 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// The website changes prices: drinks already on a tab keep theirs, the
|
||||
// next one is charged the new price
|
||||
group('addProductToTab after a price or name change', () {
|
||||
test(
|
||||
'a new price starts a new line; earlier drinks keep theirs',
|
||||
() async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final item in items) (item.unitPriceInCents, item.quantity),
|
||||
], unorderedEquals([(400, 2), (450, 1)]));
|
||||
expect((await service.getTabById(tab.id))!.totalInCents, 1250);
|
||||
},
|
||||
);
|
||||
|
||||
test('more drinks at the new price go on the new line', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
}
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect(items, hasLength(2));
|
||||
expect(
|
||||
items.firstWhere((item) => item.unitPriceInCents == 450).quantity,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('a price going back joins the line at that price again', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 300),
|
||||
);
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final item in items) (item.unitPriceInCents, item.quantity),
|
||||
], unorderedEquals([(400, 2), (300, 1)]));
|
||||
});
|
||||
|
||||
test('a renamed product starts a new line under its new name', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(name: 'Pils'),
|
||||
);
|
||||
|
||||
final items = (await service.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final item in items) item.productName,
|
||||
], unorderedEquals(['Test Beer', 'Pils']));
|
||||
});
|
||||
|
||||
test('both lines count down the stock of the one product', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
final stored = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||
expect(stored.stockQuantity, 98);
|
||||
});
|
||||
|
||||
test('settling charges each line at its own price', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
await service.closeTab(tab.id);
|
||||
|
||||
final closed = (await service.getClosedTabs()).single;
|
||||
expect(closed.totalInCents, 850);
|
||||
expect([
|
||||
for (final item in closed.items) item.unitPriceInCents,
|
||||
], unorderedEquals([400, 450]));
|
||||
});
|
||||
});
|
||||
|
||||
// Foo has 2 at the old price; the website changes the price; staff tap +
|
||||
// on Foo's line. The 2 stay at the old price, every new one is charged
|
||||
// the new price.
|
||||
group('+ on a line after a price change', () {
|
||||
Future<void> setPrice(int priceInCents, {String? name}) async {
|
||||
await (database.update(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).write(
|
||||
ProductsCompanion(
|
||||
priceInCents: Value(priceInCents),
|
||||
name: name == null ? const Value.absent() : Value(name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> fooWithTwoAtOldPrice() async {
|
||||
final tab = await service.createTab(customerName: 'Foo');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await setPrice(450);
|
||||
return tab.id;
|
||||
}
|
||||
|
||||
Future<List<(int, int)>> lines(String tabId) async {
|
||||
return [
|
||||
for (final item in (await service.getTabById(tabId))!.items)
|
||||
(item.unitPriceInCents, item.quantity),
|
||||
];
|
||||
}
|
||||
|
||||
Future<String> lineAt(String tabId, int priceInCents) async {
|
||||
return (await service.getTabById(tabId))!.items
|
||||
.firstWhere((item) => item.unitPriceInCents == priceInCents)
|
||||
.id;
|
||||
}
|
||||
|
||||
test(
|
||||
'the old drinks keep their price, the new one gets the new price',
|
||||
() async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
final added = await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(added, 1);
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)]));
|
||||
expect((await service.getTabById(tabId))!.totalInCents, 1250);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'tapping + on the old line again keeps adding at the new price',
|
||||
() async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
final oldLine = await lineAt(tabId, 400);
|
||||
|
||||
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
|
||||
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
|
||||
},
|
||||
);
|
||||
|
||||
test('+ on the line at the new price adds there', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 450),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
|
||||
});
|
||||
|
||||
test('the tile and + end up on the same new line', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: tabId,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 2)]));
|
||||
});
|
||||
|
||||
test('+ several at once adds them all at the new price', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
final added = await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 3,
|
||||
);
|
||||
|
||||
expect(added, 3);
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 3)]));
|
||||
});
|
||||
|
||||
test('− on the old line takes off a drink at the old price', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: -1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 1), (450, 1)]));
|
||||
});
|
||||
|
||||
test('a price going back makes + add to the old line again', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await setPrice(400);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), [(400, 3)]);
|
||||
});
|
||||
|
||||
test('a renamed product gets a new line under its new name', () async {
|
||||
final tab = await service.createTab(customerName: 'Foo');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await setPrice(400, name: 'Pils');
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: (await service.getTabById(tab.id))!.items.single.id,
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
final names = [
|
||||
for (final item in (await service.getTabById(tab.id))!.items)
|
||||
(item.productName, item.quantity),
|
||||
];
|
||||
expect(names, unorderedEquals([('Test Beer', 1), ('Pils', 1)]));
|
||||
});
|
||||
|
||||
test(
|
||||
'a hidden (archived) product is still charged its latest price',
|
||||
() async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals('prod-1')))
|
||||
.write(const ProductsCompanion(active: Value(false)));
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tabId), unorderedEquals([(400, 2), (450, 1)]));
|
||||
},
|
||||
);
|
||||
|
||||
test('a line whose product is gone keeps its own price', () async {
|
||||
final tab = await service.createTab(customerName: 'Foo');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await database.delete(database.products).go();
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: (await service.getTabById(tab.id))!.items.single.id,
|
||||
delta: 1,
|
||||
);
|
||||
|
||||
expect(await lines(tab.id), [(400, 2)]);
|
||||
});
|
||||
|
||||
test('another tab without the product gets the new price', () async {
|
||||
await fooWithTwoAtOldPrice();
|
||||
final bar = await service.createTab(customerName: 'Bar');
|
||||
|
||||
await service.addProductToTab(
|
||||
tabId: bar.id,
|
||||
product: createTestProduct(priceInCents: 450),
|
||||
);
|
||||
|
||||
expect(await lines(bar.id), [(450, 1)]);
|
||||
});
|
||||
|
||||
test('every drink counts down the stock of the one product', () async {
|
||||
final tabId = await fooWithTwoAtOldPrice();
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: await lineAt(tabId, 400),
|
||||
delta: 2,
|
||||
);
|
||||
|
||||
final stored = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||
expect(stored.stockQuantity, 96);
|
||||
});
|
||||
});
|
||||
|
||||
group('adjustTabItemQuantity', () {
|
||||
test('updates item quantity', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// 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)),
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,35 @@ void main() {
|
||||
];
|
||||
|
||||
group('sync', () {
|
||||
test(
|
||||
'a lost connection is reported as offline, a server error is not',
|
||||
() async {
|
||||
final offline = RemoteSyncService(
|
||||
database: database,
|
||||
barTabService: barTabService,
|
||||
apiClient: KoolTapApiClient(
|
||||
client: MockClient(
|
||||
(_) async => throw http.ClientException('Failed host lookup'),
|
||||
),
|
||||
baseUrl: _baseUrl,
|
||||
),
|
||||
secureStorage: storage,
|
||||
);
|
||||
final offlineEvents = <RemoteSyncEvent>[];
|
||||
offline.events.listen(offlineEvents.add);
|
||||
server.syncResponse = http.Response('oops', 500);
|
||||
|
||||
await offline.sync();
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(offlineEvents.single.type, RemoteSyncEventType.syncFailed);
|
||||
expect(offlineEvents.single.offline, isTrue);
|
||||
expect(events.single.offline, isFalse);
|
||||
offline.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('sends a snapshot of the open tabs and stores the cursor', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
@@ -1363,6 +1392,478 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('website manages the catalog', () {
|
||||
Map<String, dynamic> product(
|
||||
String id, {
|
||||
String name = 'Cola',
|
||||
String? category = 'Frisdrank',
|
||||
int price = 250,
|
||||
int? lowStockThreshold,
|
||||
bool archived = false,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'category': category,
|
||||
'priceInCents': price,
|
||||
'lowStockThreshold': lowStockThreshold,
|
||||
'archived': archived,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, ProductRow>> rows() async {
|
||||
return {
|
||||
for (final row in await database.select(database.products).get())
|
||||
row.id: row,
|
||||
};
|
||||
}
|
||||
|
||||
test('adds, changes and hides products like the website says', () async {
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals('prod-1')))
|
||||
.write(const ProductsCompanion(imagePath: Value('/beer.png')));
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: 'seeded',
|
||||
name: 'Seeded on this tablet',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 24,
|
||||
lowStockThreshold: 0,
|
||||
priceInCents: 100,
|
||||
),
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [
|
||||
product(
|
||||
'prod-1',
|
||||
name: 'Beer',
|
||||
category: null,
|
||||
price: 450,
|
||||
lowStockThreshold: 6,
|
||||
),
|
||||
product('cola'),
|
||||
product('old', name: 'Old', archived: true),
|
||||
],
|
||||
'stock': [
|
||||
{'productId': 'cola', 'stock': 24, 'countedAt': null},
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
final products = await rows();
|
||||
expect(
|
||||
products['prod-1'],
|
||||
isA<ProductRow>()
|
||||
.having((p) => p.priceInCents, 'price', 450)
|
||||
.having((p) => p.category, 'category', '')
|
||||
.having((p) => p.lowStockThreshold, 'threshold', 6)
|
||||
.having((p) => p.imagePath, 'image', '/beer.png')
|
||||
.having((p) => p.active, 'active', isTrue),
|
||||
);
|
||||
// New, and its stock arrives in the same sync
|
||||
expect(
|
||||
products['cola'],
|
||||
isA<ProductRow>()
|
||||
.having((p) => p.name, 'name', 'Cola')
|
||||
.having((p) => p.stockQuantity, 'stock', 24)
|
||||
.having((p) => p.infiniteStock, 'untracked', isFalse),
|
||||
);
|
||||
expect(products['old']!.active, isFalse);
|
||||
expect(products['seeded']!.active, isFalse);
|
||||
});
|
||||
|
||||
test('a product that comes back is shown again', () async {
|
||||
server.respondWith({
|
||||
'catalog': [product('prod-1', archived: true)],
|
||||
});
|
||||
await service.sync();
|
||||
expect((await rows())['prod-1']!.active, isFalse);
|
||||
|
||||
server.respondWith({
|
||||
'catalog': [product('prod-1')],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
expect((await rows())['prod-1']!.active, isTrue);
|
||||
});
|
||||
|
||||
test('a server without a catalog leaves the products alone', () async {
|
||||
await service.sync();
|
||||
|
||||
expect(
|
||||
(await rows())['prod-1'],
|
||||
isA<ProductRow>()
|
||||
.having((p) => p.name, 'name', 'Beer')
|
||||
.having((p) => p.active, 'active', isTrue),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('website catalog changes reach the bar', () {
|
||||
late DriftProductService products;
|
||||
|
||||
setUp(() {
|
||||
products = DriftProductService(database: database);
|
||||
});
|
||||
|
||||
Map<String, dynamic> beer({
|
||||
int price = 400,
|
||||
String name = 'Beer',
|
||||
bool archived = false,
|
||||
int? lowStockThreshold = 10,
|
||||
}) {
|
||||
return {
|
||||
'id': 'prod-1',
|
||||
'name': name,
|
||||
'category': 'Drinks',
|
||||
'priceInCents': price,
|
||||
'lowStockThreshold': lowStockThreshold,
|
||||
'archived': archived,
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> sentLines(int request) => [
|
||||
for (final event in server.syncRequests[request]['events'] as List)
|
||||
if (event['type'] == 'line_added') event as Map<String, dynamic>,
|
||||
];
|
||||
|
||||
test(
|
||||
'the next drink after a price change is charged the new price',
|
||||
() async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: (await products.getProductById('prod-1'))!,
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
// What the bar screen hands to the tab: the product as stored now
|
||||
final shown = (await products.getProducts()).single;
|
||||
expect(shown.priceInCents, 450);
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: shown);
|
||||
server.respondWith({});
|
||||
await service.sync();
|
||||
|
||||
final lines = (await barTabService.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final line in lines) (line.unitPriceInCents, line.quantity),
|
||||
], unorderedEquals([(400, 1), (450, 1)]));
|
||||
// Unacknowledged, so both drinks are sent again, each at its own price
|
||||
expect(
|
||||
[for (final line in sentLines(1)) line['unitPriceInCents']],
|
||||
[400, 450],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('Foo keeps 2 at the old price, + charges the new price', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Foo');
|
||||
final beerNow = (await products.getProductById('prod-1'))!;
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: beerNow);
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: beerNow);
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
// Staff tap + on Foo's line
|
||||
final oldLine = (await barTabService.getTabById(tab.id))!.items.single;
|
||||
await barTabService.adjustTabItemQuantity(
|
||||
tabItemId: oldLine.id,
|
||||
delta: 1,
|
||||
);
|
||||
server.respondWith({});
|
||||
await service.sync();
|
||||
|
||||
final lines = (await barTabService.getTabById(tab.id))!.items;
|
||||
expect([
|
||||
for (final line in lines) (line.unitPriceInCents, line.quantity),
|
||||
], unorderedEquals([(400, 2), (450, 1)]));
|
||||
expect(sentLines(1).last['unitPriceInCents'], 450);
|
||||
});
|
||||
|
||||
test(
|
||||
'the products the bar screen watches change without a restart',
|
||||
() async {
|
||||
final emitted = <List<int>>[];
|
||||
final subscription = products.watchProducts().listen(
|
||||
(list) => emitted.add([for (final p in list) p.priceInCents]),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
});
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(emitted.last, [450]);
|
||||
await subscription.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'an unchanged catalog writes nothing, so the screen does not redraw',
|
||||
() async {
|
||||
server.respondWith({
|
||||
'catalog': [beer()],
|
||||
});
|
||||
await service.sync();
|
||||
var emissions = 0;
|
||||
final subscription = products.watchProducts().listen(
|
||||
(_) => emissions++,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
await service.sync();
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
// Only the first emission: the current list
|
||||
expect(emissions, 1);
|
||||
await subscription.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a renamed product is shown under its new name, old drinks keep theirs',
|
||||
() async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: (await products.getProductById('prod-1'))!,
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [beer(name: 'Pils')],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
final shown = (await products.getProducts()).single;
|
||||
expect(shown.name, 'Pils');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: shown);
|
||||
|
||||
final names = [
|
||||
for (final line in (await barTabService.getTabById(tab.id))!.items)
|
||||
line.productName,
|
||||
];
|
||||
expect(names, unorderedEquals(['Beer', 'Pils']));
|
||||
},
|
||||
);
|
||||
|
||||
test('a product added on the website can be sold right away', () async {
|
||||
server.respondWith({
|
||||
'catalog': [
|
||||
beer(),
|
||||
{
|
||||
'id': 'web-1',
|
||||
'name': 'Ice Tea',
|
||||
'category': 'Frisdrank',
|
||||
'priceInCents': 280,
|
||||
'lowStockThreshold': 5,
|
||||
'archived': false,
|
||||
},
|
||||
],
|
||||
'stock': [
|
||||
{'productId': 'web-1', 'stock': 24, 'countedAt': null},
|
||||
],
|
||||
});
|
||||
await service.sync();
|
||||
final iceTea = (await products.getProductById('web-1'))!;
|
||||
expect(iceTea.stockQuantity, 24);
|
||||
expect(iceTea.infiniteStock, isFalse);
|
||||
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: iceTea);
|
||||
server.respondWith({});
|
||||
await service.sync();
|
||||
|
||||
expect(sentLines(1).single, containsPair('productId', 'web-1'));
|
||||
expect(sentLines(1).single, containsPair('unitPriceInCents', 280));
|
||||
expect((await products.getProductById('web-1'))!.stockQuantity, 23);
|
||||
});
|
||||
|
||||
test(
|
||||
'an archived product leaves the bar screen but stays on open tabs',
|
||||
() async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: (await products.getProductById('prod-1'))!,
|
||||
);
|
||||
server.respondWith({
|
||||
'catalog': [beer(archived: true)],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(await products.getProducts(), isEmpty);
|
||||
expect(
|
||||
(await barTabService.getTabById(tab.id))!.items.single.quantity,
|
||||
1,
|
||||
);
|
||||
// The product form can still open it, e.g. from the history
|
||||
expect(await products.getProductById('prod-1'), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('no warning level on the website is 0 on the tablet', () async {
|
||||
server.respondWith({
|
||||
'catalog': [beer(lowStockThreshold: null)],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect((await products.getProductById('prod-1'))!.lowStockThreshold, 0);
|
||||
});
|
||||
|
||||
test('a price change leaves the lines of the server tabs alone', () async {
|
||||
server.respondWith({
|
||||
'catalog': [beer(price: 450)],
|
||||
'tabs': [
|
||||
{
|
||||
'id': 'tab-1',
|
||||
'customerName': 'Alice',
|
||||
'openedAt': '2026-09-24T20:00:00.000Z',
|
||||
'version': 2,
|
||||
'lines': [
|
||||
{
|
||||
'id': 'line-1',
|
||||
'productId': 'prod-1',
|
||||
'productName': 'Beer',
|
||||
'unitPriceInCents': 400,
|
||||
'quantity': 2,
|
||||
'units': [
|
||||
{
|
||||
'id': 'u1',
|
||||
'addedAt': '2026-09-24T20:00:00.000Z',
|
||||
'removedAt': null,
|
||||
},
|
||||
{
|
||||
'id': 'u2',
|
||||
'addedAt': '2026-09-24T20:01:00.000Z',
|
||||
'removedAt': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
final tab = (await barTabService.getTabById('tab-1'))!;
|
||||
expect(tab.items.single.unitPriceInCents, 400);
|
||||
expect(tab.totalInCents, 800);
|
||||
});
|
||||
});
|
||||
|
||||
group('a part of the response that fails', () {
|
||||
Map<String, dynamic> brokenTab() {
|
||||
// Two lines claiming the same unit: the database refuses the second
|
||||
Map<String, dynamic> line(String id) => {
|
||||
'id': id,
|
||||
'productId': 'prod-1',
|
||||
'productName': 'Beer',
|
||||
'unitPriceInCents': 400,
|
||||
'quantity': 1,
|
||||
'units': [
|
||||
{
|
||||
'id': 'same-unit',
|
||||
'addedAt': '2026-09-24T20:00:00.000Z',
|
||||
'removedAt': null,
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
'id': 'tab-1',
|
||||
'customerName': 'Alice',
|
||||
'openedAt': '2026-09-24T20:00:00.000Z',
|
||||
'version': 1,
|
||||
'lines': [line('line-1'), line('line-2')],
|
||||
};
|
||||
}
|
||||
|
||||
test('does not keep prices and stock from arriving', () async {
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
'catalog': [
|
||||
{
|
||||
'id': 'prod-1',
|
||||
'name': 'Beer',
|
||||
'category': 'Drinks',
|
||||
'priceInCents': 450,
|
||||
'lowStockThreshold': 10,
|
||||
'archived': false,
|
||||
},
|
||||
],
|
||||
'stock': [
|
||||
{'productId': 'prod-1', 'stock': 42, 'countedAt': null},
|
||||
],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
final beer = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals('prod-1'))).getSingle();
|
||||
expect(beer.priceInCents, 450);
|
||||
expect(beer.stockQuantity, 42);
|
||||
});
|
||||
|
||||
test('is reported as a failed sync naming the part', () async {
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
final failed = events.where(
|
||||
(e) => e.type == RemoteSyncEventType.syncFailed,
|
||||
);
|
||||
expect(failed.single.message, startsWith('tabs: '));
|
||||
expect(eventTypes(), isNot(contains(RemoteSyncEventType.syncCompleted)));
|
||||
});
|
||||
|
||||
test('leaves the local tabs as they were', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Bob');
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
});
|
||||
|
||||
await service.sync();
|
||||
|
||||
expect(await barTabService.getTabById(tab.id), isNotNull);
|
||||
expect(await barTabService.getTabById('tab-1'), isNull);
|
||||
});
|
||||
|
||||
test('a later good response is applied normally', () async {
|
||||
server.respondWith({
|
||||
'tabs': [brokenTab()],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
server.respondWith({
|
||||
'tabs': [
|
||||
{...brokenTab(), 'lines': <Object>[]},
|
||||
],
|
||||
});
|
||||
await service.sync();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(await barTabService.getTabById('tab-1'), isNotNull);
|
||||
expect(eventTypes().last, RemoteSyncEventType.syncCompleted);
|
||||
});
|
||||
});
|
||||
|
||||
group('server holds the stock', () {
|
||||
Future<ProductRow> beer() {
|
||||
return (database.select(
|
||||
|
||||
+69
-13
@@ -99,23 +99,79 @@ void main() {
|
||||
expect(row.id, event['id']);
|
||||
});
|
||||
|
||||
test('adding to an existing line keeps that line\'s price', () async {
|
||||
test(
|
||||
'a drink at a new price goes on a new line, with that price',
|
||||
() async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: _cola.copyWith(priceInCents: 300),
|
||||
);
|
||||
|
||||
final events = await outbox();
|
||||
expect(events.map((event) => event['unitPriceInCents']), [250, 300]);
|
||||
expect(events.map((event) => event['productId']), ['prod-1', 'prod-1']);
|
||||
// A different line, so the server doesn't put it on the old one either
|
||||
expect(events[0]['lineId'], isNot(events[1]['lineId']));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'+ on a line at an old price sends the new price, on a new line',
|
||||
() async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
final oldLine = await lineIdOf(tab.id);
|
||||
await (database.update(database.products)
|
||||
..where((row) => row.id.equals('prod-1')))
|
||||
.write(const ProductsCompanion(priceInCents: Value(300)));
|
||||
|
||||
await service.adjustTabItemQuantity(tabItemId: oldLine, delta: 1);
|
||||
|
||||
final added = (await outbox()).last;
|
||||
expect(added['type'], 'line_added');
|
||||
expect(added['unitPriceInCents'], 300);
|
||||
expect(added['quantity'], 1);
|
||||
expect(added['lineId'], isNot(oldLine));
|
||||
},
|
||||
);
|
||||
|
||||
test('a line without a product sends no productId', () async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: const Product(
|
||||
id: 'prod-1',
|
||||
name: 'Cola',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 100,
|
||||
lowStockThreshold: 10,
|
||||
priceInCents: 300,
|
||||
),
|
||||
// As taken over from the tablet's own tabs: the line has no product
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: 'seed:${tab.id}:0',
|
||||
tabId: tab.id,
|
||||
productId: '',
|
||||
productName: 'Cola',
|
||||
quantity: 1,
|
||||
unitPriceInCents: 250,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
await service.adjustTabItemQuantity(
|
||||
tabItemId: 'seed:${tab.id}:0',
|
||||
delta: -1,
|
||||
);
|
||||
|
||||
final event = (await outbox()).last;
|
||||
expect(event['type'], 'line_removed');
|
||||
expect(event.containsKey('productId'), isFalse);
|
||||
expect(event['productName'], 'Cola');
|
||||
});
|
||||
|
||||
test('a drink at the same price goes on the same line', () async {
|
||||
final tab = await openTab('Jan');
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||
|
||||
final events = await outbox();
|
||||
expect(events.map((event) => event['unitPriceInCents']), [250, 250]);
|
||||
expect(events[0]['lineId'], events[1]['lineId']);
|
||||
});
|
||||
|
||||
test('changing a quantity writes the difference', () async {
|
||||
|
||||
Reference in New Issue
Block a user