fix: alot
This commit is contained in:
@@ -3,46 +3,60 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/app/app_lifecycle_lock.dart';
|
||||
|
||||
void main() {
|
||||
test('locks when the app is no longer active', () {
|
||||
var lockCount = 0;
|
||||
final observer = AppLifecycleLockObserver(onLock: () => lockCount++);
|
||||
late DateTime now;
|
||||
late int lockCount;
|
||||
|
||||
for (final state in [
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.paused,
|
||||
AppLifecycleState.detached,
|
||||
]) {
|
||||
observer.didChangeAppLifecycleState(state);
|
||||
}
|
||||
AppLifecycleLockObserver observer({bool Function()? shouldLock}) {
|
||||
return AppLifecycleLockObserver(
|
||||
onLock: () => lockCount++,
|
||||
shouldLock: shouldLock,
|
||||
clock: () => now,
|
||||
);
|
||||
}
|
||||
|
||||
expect(lockCount, 4);
|
||||
setUp(() {
|
||||
now = DateTime(2026, 1, 1, 20);
|
||||
lockCount = 0;
|
||||
});
|
||||
|
||||
test('does not lock while the app is active', () {
|
||||
var lockCount = 0;
|
||||
final observer = AppLifecycleLockObserver(onLock: () => lockCount++);
|
||||
test('locks when the app returns after the grace period', () {
|
||||
final lock = observer();
|
||||
|
||||
observer.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
now = now.add(const Duration(seconds: 30));
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
|
||||
expect(lockCount, 1);
|
||||
});
|
||||
|
||||
test('does not lock after a short trip to the background', () {
|
||||
final lock = observer();
|
||||
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
now = now.add(const Duration(seconds: 10));
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
|
||||
expect(lockCount, 0);
|
||||
});
|
||||
|
||||
test('does not lock when only inactive', () {
|
||||
final lock = observer();
|
||||
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||||
now = now.add(const Duration(minutes: 5));
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
|
||||
expect(lockCount, 0);
|
||||
});
|
||||
|
||||
test('does not lock when auto-lock is disabled', () {
|
||||
var lockCount = 0;
|
||||
final observer = AppLifecycleLockObserver(
|
||||
onLock: () => lockCount++,
|
||||
shouldLock: () => false,
|
||||
);
|
||||
final lock = observer(shouldLock: () => false);
|
||||
|
||||
for (final state in [
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.paused,
|
||||
AppLifecycleState.detached,
|
||||
]) {
|
||||
observer.didChangeAppLifecycleState(state);
|
||||
}
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
now = now.add(const Duration(minutes: 5));
|
||||
lock.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
|
||||
expect(lockCount, 0);
|
||||
});
|
||||
|
||||
@@ -131,4 +131,14 @@ void main() {
|
||||
expect(viewModel.loadError, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('finds an open tab by name, ignoring case and spaces', () async {
|
||||
viewModel.ensureLoaded();
|
||||
openTabs.add([_tab('Jan'), _tab('Piet')]);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(viewModel.hasOpenTabNamed(' jan '), true);
|
||||
expect(viewModel.hasOpenTabNamed('Jan', exceptTabId: 'Jan'), false);
|
||||
expect(viewModel.hasOpenTabNamed('Joris'), false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -565,5 +565,22 @@ void main() {
|
||||
expect(closedTabs.last.customerName, 'First');
|
||||
});
|
||||
});
|
||||
|
||||
group('hidden products', () {
|
||||
test('a tab holding a deleted product can still be changed', () async {
|
||||
final tab = await service.createTab(customerName: 'John');
|
||||
await service.addProductToTab(
|
||||
tabId: tab.id,
|
||||
product: createTestProduct(),
|
||||
);
|
||||
await DriftProductService(database: database).deleteProduct('prod-1');
|
||||
|
||||
final item = (await service.getTabById(tab.id))!.items.single;
|
||||
await service.adjustTabItemQuantity(tabItemId: item.id, delta: -1);
|
||||
await service.deleteTab(tab.id);
|
||||
|
||||
expect(await service.getTabById(tab.id), isNull);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/models/closed_tab.dart';
|
||||
import 'package:kooltap/models/closed_tab_item.dart';
|
||||
import 'package:kooltap/models/payment_method.dart';
|
||||
|
||||
ClosedTabItem _item({
|
||||
required String id,
|
||||
int quantity = 1,
|
||||
int unitPriceInCents = 400,
|
||||
DateTime? removedAt,
|
||||
}) {
|
||||
return ClosedTabItem(
|
||||
id: id,
|
||||
closedTabId: 'closed-1',
|
||||
productId: 'prod-1',
|
||||
productName: 'Beer',
|
||||
quantity: quantity,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
purchasedAt: DateTime(2026, 1, 1, 20),
|
||||
removedAt: removedAt,
|
||||
);
|
||||
}
|
||||
|
||||
ClosedTab _tab(List<ClosedTabItem> items) {
|
||||
return ClosedTab(
|
||||
id: 'closed-1',
|
||||
originalTabId: 'tab-1',
|
||||
customerName: 'John',
|
||||
closedAt: DateTime(2026, 1, 1, 23),
|
||||
paymentMethod: PaymentMethod.cash,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ClosedTabItem', () {
|
||||
test('charges quantity times unit price', () {
|
||||
expect(
|
||||
_item(id: 'a', quantity: 3, unitPriceInCents: 250).lineTotalInCents,
|
||||
750,
|
||||
);
|
||||
});
|
||||
|
||||
test('charges nothing once removed', () {
|
||||
final removed = _item(id: 'a', removedAt: DateTime(2026, 1, 1, 21));
|
||||
|
||||
expect(removed.isRemoved, true);
|
||||
expect(removed.lineTotalInCents, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClosedTab', () {
|
||||
test('totals only the purchases that were not removed', () {
|
||||
final tab = _tab([
|
||||
_item(id: 'a', unitPriceInCents: 400),
|
||||
_item(id: 'b', unitPriceInCents: 400),
|
||||
_item(id: 'c', unitPriceInCents: 600),
|
||||
_item(id: 'd', unitPriceInCents: 400, removedAt: DateTime(2026)),
|
||||
]);
|
||||
|
||||
expect(tab.totalInCents, 1400);
|
||||
expect(tab.itemCount, 3);
|
||||
});
|
||||
|
||||
test('is zero when every purchase was removed', () {
|
||||
final tab = _tab([
|
||||
_item(id: 'a', removedAt: DateTime(2026)),
|
||||
_item(id: 'b', removedAt: DateTime(2026)),
|
||||
]);
|
||||
|
||||
expect(tab.totalInCents, 0);
|
||||
expect(tab.itemCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('PaymentMethod', () {
|
||||
test('round-trips through its stored value', () {
|
||||
for (final method in PaymentMethod.values) {
|
||||
expect(PaymentMethod.fromValue(method.value), method);
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to cash for an unknown value', () {
|
||||
expect(PaymentMethod.fromValue('bitcoin'), PaymentMethod.cash);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/l10n/app_localizations.dart';
|
||||
import 'package:kooltap/models/bar_tab.dart';
|
||||
import 'package:kooltap/models/tab_item.dart';
|
||||
import 'package:kooltap/services/admin_pin_service.dart';
|
||||
import 'package:kooltap/services/bar_tab_service.dart';
|
||||
import 'package:kooltap/services/pin_lock_service.dart';
|
||||
import 'package:kooltap/viewmodels/admin_pin_view_model.dart';
|
||||
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
|
||||
import 'package:kooltap/views/dialogs/edit_tab_dialog.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class MockBarTabService extends Mock implements BarTabService {}
|
||||
|
||||
class MockAdminPinService extends Mock implements AdminPinService {}
|
||||
|
||||
BarTab _tab({required bool withItems}) {
|
||||
return BarTab(
|
||||
id: 'tab-1',
|
||||
customerName: 'John',
|
||||
openedAt: DateTime(2026),
|
||||
items: [
|
||||
if (withItems)
|
||||
const TabItem(
|
||||
id: 'item-1',
|
||||
tabId: 'tab-1',
|
||||
productId: 'beer',
|
||||
productName: 'Beer',
|
||||
quantity: 2,
|
||||
unitPriceInCents: 250,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MockBarTabService barTabService;
|
||||
late MockAdminPinService adminPinService;
|
||||
late AdminPinViewModel adminPinViewModel;
|
||||
|
||||
setUp(() {
|
||||
barTabService = MockBarTabService();
|
||||
adminPinService = MockAdminPinService();
|
||||
adminPinViewModel = AdminPinViewModel(adminPinService: adminPinService);
|
||||
|
||||
when(() => barTabService.deleteTab(any())).thenAnswer((_) async {});
|
||||
when(() => adminPinService.hasPin()).thenAnswer((_) async => true);
|
||||
when(() => adminPinService.verifyPin('1234')).thenAnswer((_) async => true);
|
||||
when(
|
||||
() => adminPinService.verifyPin('0000'),
|
||||
).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
Future<void> openDialog(WidgetTester tester, BarTab tab) async {
|
||||
await adminPinViewModel.load();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<BarScreenViewModel>(
|
||||
create: (_) => BarScreenViewModel(barTabService: barTabService),
|
||||
),
|
||||
ChangeNotifierProvider<AdminPinViewModel>.value(
|
||||
value: adminPinViewModel,
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: ElevatedButton(
|
||||
onPressed: () => confirmDeleteTab(context, tab),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> confirmWithPin(WidgetTester tester, String? pin) async {
|
||||
if (pin != null) await tester.enterText(find.byType(TextField), pin);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('deletes an empty tab without asking for a PIN', (tester) async {
|
||||
await openDialog(tester, _tab(withItems: false));
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
await confirmWithPin(tester, null);
|
||||
|
||||
verify(() => barTabService.deleteTab('tab-1')).called(1);
|
||||
});
|
||||
|
||||
testWidgets('deletes a tab with drinks after the right admin PIN', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester, _tab(withItems: true));
|
||||
await confirmWithPin(tester, '1234');
|
||||
|
||||
verify(() => barTabService.deleteTab('tab-1')).called(1);
|
||||
expect(adminPinViewModel.isUnlocked, false);
|
||||
});
|
||||
|
||||
testWidgets('keeps a tab with drinks after a wrong admin PIN', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester, _tab(withItems: true));
|
||||
await confirmWithPin(tester, '0000');
|
||||
|
||||
verifyNever(() => barTabService.deleteTab(any()));
|
||||
});
|
||||
|
||||
testWidgets('keeps a tab with drinks while the PIN is locked out', (
|
||||
tester,
|
||||
) async {
|
||||
when(
|
||||
() => adminPinService.verifyPin(any()),
|
||||
).thenThrow(const PinLockedOutException(Duration(minutes: 1)));
|
||||
|
||||
await openDialog(tester, _tab(withItems: true));
|
||||
await confirmWithPin(tester, '1234');
|
||||
|
||||
verifyNever(() => barTabService.deleteTab(any()));
|
||||
});
|
||||
|
||||
testWidgets('cancelling deletes nothing', (tester) async {
|
||||
await openDialog(tester, _tab(withItems: true));
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(() => barTabService.deleteTab(any()));
|
||||
});
|
||||
|
||||
testWidgets('does not ask for a PIN when no admin PIN is set', (
|
||||
tester,
|
||||
) async {
|
||||
when(() => adminPinService.hasPin()).thenAnswer((_) async => false);
|
||||
|
||||
await openDialog(tester, _tab(withItems: true));
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
await confirmWithPin(tester, null);
|
||||
|
||||
verify(() => barTabService.deleteTab('tab-1')).called(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:kooltap/database/app_database.dart';
|
||||
import 'package:kooltap/models/payment_method.dart';
|
||||
import 'package:kooltap/models/product.dart';
|
||||
import 'package:kooltap/services/bar_tab_service.dart';
|
||||
import 'package:kooltap/services/export_service.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
const _beer = Product(
|
||||
id: 'beer',
|
||||
name: 'Beer',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 10,
|
||||
lowStockThreshold: 2,
|
||||
priceInCents: 250,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late AppDatabase database;
|
||||
late DriftBarTabService tabs;
|
||||
late List<http.Request> requests;
|
||||
late http.Response Function() respond;
|
||||
|
||||
DefaultExportService exportService({String? token}) {
|
||||
return DefaultExportService(
|
||||
barTabService: tabs,
|
||||
authToken: () async => token,
|
||||
client: MockClient((request) async {
|
||||
requests.add(request);
|
||||
return respond();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
PackageInfo.setMockInitialValues(
|
||||
appName: 'KoolTap',
|
||||
packageName: 'com.kooltap',
|
||||
version: '1.2.12',
|
||||
buildNumber: '1',
|
||||
buildSignature: '',
|
||||
);
|
||||
|
||||
database = AppDatabase(NativeDatabase.memory());
|
||||
tabs = DriftBarTabService(database: database);
|
||||
requests = [];
|
||||
respond = () => http.Response('', 201);
|
||||
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: _beer.id,
|
||||
name: _beer.name,
|
||||
category: _beer.category,
|
||||
stockQuantity: _beer.stockQuantity,
|
||||
lowStockThreshold: _beer.lowStockThreshold,
|
||||
priceInCents: _beer.priceInCents,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('backs up the totals that were charged', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
}
|
||||
final item = (await tabs.getTabById(tab.id))!.items.single;
|
||||
await tabs.adjustTabItemQuantity(tabItemId: item.id, delta: -1);
|
||||
await tabs.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq);
|
||||
|
||||
await exportService().uploadToServer();
|
||||
|
||||
final body = jsonDecode(requests.single.body) as Map<String, dynamic>;
|
||||
final closed = (body['closedTabs'] as List).single as Map<String, dynamic>;
|
||||
final items = closed['items'] as List;
|
||||
|
||||
expect(body['appVersion'], '1.2.12');
|
||||
expect(closed['customerName'], 'John');
|
||||
expect(closed['paymentMethod'], 'payconiq');
|
||||
expect(closed['totalInCents'], 500);
|
||||
expect(closed['itemCount'], 2);
|
||||
expect(items, hasLength(3));
|
||||
expect(items.where((item) => item['isRemoved'] == true), hasLength(1));
|
||||
expect(
|
||||
items.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + (item['lineTotalInCents'] as int),
|
||||
),
|
||||
closed['totalInCents'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sends the device token when linked', () async {
|
||||
await exportService(token: 'secret').uploadToServer();
|
||||
|
||||
expect(requests.single.headers['Authorization'], 'Bearer secret');
|
||||
});
|
||||
|
||||
test('sends no token when not linked', () async {
|
||||
await exportService().uploadToServer();
|
||||
|
||||
expect(requests.single.headers.containsKey('Authorization'), false);
|
||||
});
|
||||
|
||||
test('never throws when the server is unreachable', () async {
|
||||
respond = () => throw http.ClientException('offline');
|
||||
|
||||
await expectLater(exportService().uploadToServer(), completes);
|
||||
});
|
||||
|
||||
test('never throws when the server errors', () async {
|
||||
respond = () => http.Response('oops', 500);
|
||||
|
||||
await expectLater(exportService().uploadToServer(), completes);
|
||||
});
|
||||
}
|
||||
@@ -19,4 +19,49 @@ void main() {
|
||||
expect(123456.asEuro, '€ 1.234,56');
|
||||
});
|
||||
});
|
||||
|
||||
group('parseEuroToCents', () {
|
||||
test('accepts a comma or a dot as decimal separator', () {
|
||||
expect(parseEuroToCents('2,50'), 250);
|
||||
expect(parseEuroToCents('2.50'), 250);
|
||||
});
|
||||
|
||||
test('accepts whole euros and a single decimal', () {
|
||||
expect(parseEuroToCents('3'), 300);
|
||||
expect(parseEuroToCents('3,5'), 350);
|
||||
expect(parseEuroToCents('0'), 0);
|
||||
});
|
||||
|
||||
test('ignores surrounding whitespace', () {
|
||||
expect(parseEuroToCents(' 4,20 '), 420);
|
||||
});
|
||||
|
||||
test('is exact where doubles would round', () {
|
||||
expect(parseEuroToCents('0,29'), 29);
|
||||
expect(parseEuroToCents('1,15'), 115);
|
||||
expect(parseEuroToCents('4,35'), 435);
|
||||
});
|
||||
|
||||
test('rejects more than two decimals instead of rounding', () {
|
||||
expect(parseEuroToCents('2,505'), isNull);
|
||||
});
|
||||
|
||||
test('rejects anything that is not a plain amount', () {
|
||||
for (final input in [
|
||||
'',
|
||||
'-1',
|
||||
'abc',
|
||||
'1e3',
|
||||
'NaN',
|
||||
'Infinity',
|
||||
'1.000,50',
|
||||
'2,',
|
||||
',50',
|
||||
'€2',
|
||||
'1234567',
|
||||
]) {
|
||||
expect(parseEuroToCents(input), isNull, reason: input);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ void main() {
|
||||
});
|
||||
|
||||
group('deleteProduct', () {
|
||||
test('removes product from database', () async {
|
||||
test('hides the product but keeps its row', () async {
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
@@ -187,8 +187,11 @@ void main() {
|
||||
|
||||
await service.deleteProduct('delete-test');
|
||||
|
||||
final products = await database.select(database.products).get();
|
||||
expect(products, isEmpty);
|
||||
expect(await service.getProducts(), isEmpty);
|
||||
final row = await service.getProductById('delete-test');
|
||||
expect(row!.active, isFalse);
|
||||
|
||||
await service.increaseStock('delete-test', 1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -146,6 +146,21 @@ void main() {
|
||||
expect(server.syncRequests.last['cursor'], 'cursor-1');
|
||||
});
|
||||
|
||||
test('leaves undone drinks out of the snapshot total', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
final item = (await barTabService.getTabById(tab.id))!.items.single;
|
||||
await barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: -2);
|
||||
|
||||
await service.sync();
|
||||
|
||||
final snapshot = (server.syncRequests.single['openTabs'] as List).single;
|
||||
expect(snapshot['totalInCents'], 0);
|
||||
expect(snapshot['itemCount'], 0);
|
||||
expect(snapshot['items'], isEmpty);
|
||||
});
|
||||
|
||||
test('reports a server error as a failure, not a success', () async {
|
||||
server.syncResponse = http.Response('oops', 500);
|
||||
|
||||
@@ -222,6 +237,31 @@ void main() {
|
||||
expect(eventTypes(), contains(RemoteSyncEventType.tabClosedRemotely));
|
||||
});
|
||||
|
||||
test('charges what the dashboard saw when it asked', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
}
|
||||
await service.sync();
|
||||
final seen = (server.syncRequests.last['openTabs'] as List).single;
|
||||
|
||||
server.respondWith({
|
||||
'commands': [
|
||||
_closeCommand(
|
||||
id: 'cmd-1',
|
||||
tabId: tab.id,
|
||||
expectedVersion: seen['version'] as int,
|
||||
),
|
||||
],
|
||||
});
|
||||
await service.sync();
|
||||
|
||||
final closed = (await barTabService.getClosedTabs()).single;
|
||||
expect(closed.totalInCents, seen['totalInCents']);
|
||||
expect(closed.totalInCents, 1200);
|
||||
expect((await barTabService.getTabById(tab.id))!.totalInCents, 0);
|
||||
});
|
||||
|
||||
test('refuses to close a tab that changed since the request', () async {
|
||||
final tab = await barTabService.createTab(customerName: 'Alice');
|
||||
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/database/app_database.dart';
|
||||
import 'package:kooltap/models/payment_method.dart';
|
||||
import 'package:kooltap/models/product.dart';
|
||||
import 'package:kooltap/services/bar_tab_service.dart';
|
||||
import 'package:kooltap/services/product_service.dart';
|
||||
|
||||
const _beer = Product(
|
||||
id: 'beer',
|
||||
name: 'Beer',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 50,
|
||||
lowStockThreshold: 5,
|
||||
priceInCents: 250,
|
||||
);
|
||||
|
||||
const _wine = Product(
|
||||
id: 'wine',
|
||||
name: 'Wine',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 20,
|
||||
lowStockThreshold: 5,
|
||||
priceInCents: 450,
|
||||
);
|
||||
|
||||
const _water = Product(
|
||||
id: 'water',
|
||||
name: 'Tap water',
|
||||
category: 'Drinks',
|
||||
stockQuantity: 0,
|
||||
lowStockThreshold: 0,
|
||||
infiniteStock: true,
|
||||
priceInCents: 0,
|
||||
);
|
||||
|
||||
/// End-to-end checks on what customers are charged and what leaves the
|
||||
/// fridge, against a real (in-memory) database.
|
||||
void main() {
|
||||
late AppDatabase database;
|
||||
late DriftBarTabService tabs;
|
||||
late DriftProductService products;
|
||||
|
||||
Future<void> insertProduct(Product product) {
|
||||
return database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
category: product.category,
|
||||
stockQuantity: product.stockQuantity,
|
||||
lowStockThreshold: product.lowStockThreshold,
|
||||
infiniteStock: Value(product.infiniteStock),
|
||||
priceInCents: product.priceInCents,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> stockOf(String productId) async {
|
||||
return (await products.getProductById(productId))!.stockQuantity;
|
||||
}
|
||||
|
||||
Future<int> quantityOnOpenTabs(String productId) async {
|
||||
final open = await tabs.getOpenTabs();
|
||||
return open
|
||||
.expand((tab) => tab.items)
|
||||
.where((item) => item.productId == productId)
|
||||
.fold<int>(0, (sum, item) => sum + item.quantity);
|
||||
}
|
||||
|
||||
Future<int> soldInHistory(String productId) async {
|
||||
final closed = await tabs.getClosedTabs();
|
||||
return closed
|
||||
.expand((tab) => tab.items)
|
||||
.where((item) => item.productId == productId && !item.isRemoved)
|
||||
.fold<int>(0, (sum, item) => sum + item.quantity);
|
||||
}
|
||||
|
||||
Future<String> itemId(String tabId, String productId) async {
|
||||
final tab = (await tabs.getTabById(tabId))!;
|
||||
return tab.items.firstWhere((item) => item.productId == productId).id;
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
database = AppDatabase(NativeDatabase.memory());
|
||||
tabs = DriftBarTabService(database: database);
|
||||
products = DriftProductService(database: database);
|
||||
|
||||
await insertProduct(_beer);
|
||||
await insertProduct(_wine);
|
||||
await insertProduct(_water);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
group('closing a tab', () {
|
||||
test('charges exactly what the open tab showed', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
}
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||
await tabs.adjustTabItemQuantity(
|
||||
tabItemId: await itemId(tab.id, 'beer'),
|
||||
delta: -1,
|
||||
);
|
||||
|
||||
final open = (await tabs.getTabById(tab.id))!;
|
||||
expect(open.totalInCents, 2 * 250 + 450);
|
||||
|
||||
await tabs.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq);
|
||||
|
||||
final closed = (await tabs.getClosedTabs()).single;
|
||||
expect(closed.totalInCents, open.totalInCents);
|
||||
expect(closed.itemCount, open.itemCount);
|
||||
expect(closed.paymentMethod, PaymentMethod.payconiq);
|
||||
expect(closed.items.where((item) => item.isRemoved), hasLength(1));
|
||||
});
|
||||
|
||||
test('stores each payment method as chosen', () async {
|
||||
for (final method in PaymentMethod.values) {
|
||||
final tab = await tabs.createTab(customerName: method.name);
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
await tabs.closeTab(tab.id, paymentMethod: method);
|
||||
}
|
||||
|
||||
final closed = await tabs.getClosedTabs();
|
||||
expect(
|
||||
{for (final tab in closed) tab.customerName: tab.paymentMethod},
|
||||
{for (final method in PaymentMethod.values) method.name: method},
|
||||
);
|
||||
});
|
||||
|
||||
test('does not record anything for an empty tab', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
|
||||
await tabs.closeTab(tab.id);
|
||||
|
||||
expect(await tabs.getClosedTabs(), isEmpty);
|
||||
});
|
||||
|
||||
test('records a tab whose drinks were all undone at zero', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
await tabs.adjustTabItemQuantity(
|
||||
tabItemId: await itemId(tab.id, 'beer'),
|
||||
delta: -1,
|
||||
);
|
||||
|
||||
await tabs.closeTab(tab.id);
|
||||
|
||||
final closed = (await tabs.getClosedTabs()).single;
|
||||
expect(closed.totalInCents, 0);
|
||||
expect(closed.items.single.isRemoved, true);
|
||||
});
|
||||
|
||||
test('never charges the same drink twice', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
await tabs.closeTab(tab.id);
|
||||
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||
await tabs.closeTab(tab.id);
|
||||
await tabs.closeTab(tab.id);
|
||||
|
||||
final closed = await tabs.getClosedTabs();
|
||||
expect(closed, hasLength(2));
|
||||
expect(
|
||||
closed.map((tab) => tab.totalInCents),
|
||||
unorderedEquals([250, 450]),
|
||||
);
|
||||
expect((await tabs.getTabById(tab.id))!.items, isEmpty);
|
||||
});
|
||||
|
||||
test('keeps the tab open under the same name', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
|
||||
await tabs.closeTab(tab.id);
|
||||
|
||||
final reopened = (await tabs.getTabById(tab.id))!;
|
||||
expect(reopened.customerName, 'John');
|
||||
expect(reopened.totalInCents, 0);
|
||||
});
|
||||
|
||||
test('a failed version check changes nothing', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
final staleVersion = (await tabs.getTabById(tab.id))!.version;
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
|
||||
await expectLater(
|
||||
tabs.closeTab(tab.id, expectedVersion: staleVersion),
|
||||
throwsA(isA<TabVersionConflictException>()),
|
||||
);
|
||||
|
||||
expect(await tabs.getClosedTabs(), isEmpty);
|
||||
expect((await tabs.getTabById(tab.id))!.totalInCents, 500);
|
||||
});
|
||||
});
|
||||
|
||||
group('prices', () {
|
||||
test('a tab keeps the price a drink was rung up at', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
|
||||
await products.updateProduct(_beer.copyWith(priceInCents: 300));
|
||||
await tabs.closeTab(tab.id);
|
||||
|
||||
expect((await tabs.getClosedTabs()).single.totalInCents, 250);
|
||||
});
|
||||
|
||||
test('a new tab uses the current price', () async {
|
||||
final updated = _beer.copyWith(priceInCents: 300);
|
||||
await products.updateProduct(updated);
|
||||
|
||||
final tab = await tabs.createTab(customerName: 'Jane');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: updated);
|
||||
|
||||
expect((await tabs.getTabById(tab.id))!.totalInCents, 300);
|
||||
});
|
||||
|
||||
test('free products add nothing to the total', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _water);
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
|
||||
final open = (await tabs.getTabById(tab.id))!;
|
||||
expect(open.totalInCents, 250);
|
||||
expect(open.itemCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('stock', () {
|
||||
test('every bottle is either in the fridge, on a tab or sold', () async {
|
||||
final random = Random(42);
|
||||
final tabIds = [
|
||||
for (final name in ['A', 'B', 'C', 'D'])
|
||||
(await tabs.createTab(customerName: name)).id,
|
||||
];
|
||||
|
||||
for (var step = 0; step < 300; step++) {
|
||||
final tabId = tabIds[random.nextInt(tabIds.length)];
|
||||
final product = random.nextBool() ? _beer : _wine;
|
||||
|
||||
try {
|
||||
switch (random.nextInt(10)) {
|
||||
case < 6:
|
||||
await tabs.addProductToTab(tabId: tabId, product: product);
|
||||
case < 8:
|
||||
final tab = (await tabs.getTabById(tabId))!;
|
||||
if (tab.items.isEmpty) continue;
|
||||
final item = tab.items[random.nextInt(tab.items.length)];
|
||||
await tabs.adjustTabItemQuantity(
|
||||
tabItemId: item.id,
|
||||
delta: random.nextInt(5) - 2,
|
||||
);
|
||||
case 8:
|
||||
await tabs.closeTab(tabId);
|
||||
default:
|
||||
await tabs.deleteTab(tabId);
|
||||
tabIds.remove(tabId);
|
||||
tabIds.add((await tabs.createTab(customerName: 'New')).id);
|
||||
}
|
||||
} on InsufficientStockException {
|
||||
// Running out is part of the scenario.
|
||||
}
|
||||
|
||||
for (final product in [_beer, _wine]) {
|
||||
final accounted =
|
||||
await stockOf(product.id) +
|
||||
await quantityOnOpenTabs(product.id) +
|
||||
await soldInHistory(product.id);
|
||||
expect(accounted, product.stockQuantity, reason: 'step $step');
|
||||
expect(await stockOf(product.id), greaterThanOrEqualTo(0));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('can never sell more than is in stock', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
var sold = 0;
|
||||
|
||||
for (var i = 0; i < _wine.stockQuantity + 5; i++) {
|
||||
try {
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||
sold++;
|
||||
} on InsufficientStockException {
|
||||
// Expected once the wine runs out.
|
||||
}
|
||||
}
|
||||
|
||||
expect(sold, _wine.stockQuantity);
|
||||
expect(await stockOf('wine'), 0);
|
||||
expect((await tabs.getTabById(tab.id))!.totalInCents, 20 * 450);
|
||||
});
|
||||
|
||||
test('simultaneous orders for the last bottle sell it once', () async {
|
||||
await products.updateProduct(_wine.copyWith(stockQuantity: 1));
|
||||
final first = await tabs.createTab(customerName: 'A');
|
||||
final second = await tabs.createTab(customerName: 'B');
|
||||
|
||||
final results = await Future.wait([
|
||||
for (final tab in [first, second])
|
||||
tabs
|
||||
.addProductToTab(tabId: tab.id, product: _wine)
|
||||
.then((_) => true)
|
||||
.catchError(
|
||||
(_) => false,
|
||||
test: (e) => e is InsufficientStockException,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(results.where((ok) => ok), hasLength(1));
|
||||
expect(await stockOf('wine'), 0);
|
||||
expect(await quantityOnOpenTabs('wine'), 1);
|
||||
});
|
||||
|
||||
test('raising a quantity past the stock adds nothing', () async {
|
||||
await products.updateProduct(_wine.copyWith(stockQuantity: 2));
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||
|
||||
await expectLater(
|
||||
tabs.adjustTabItemQuantity(
|
||||
tabItemId: await itemId(tab.id, 'wine'),
|
||||
delta: 5,
|
||||
),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
);
|
||||
|
||||
expect(await stockOf('wine'), 1);
|
||||
expect((await tabs.getTabById(tab.id))!.items.single.quantity, 1);
|
||||
});
|
||||
|
||||
test('lowering past zero only returns what was on the tab', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
|
||||
final applied = await tabs.adjustTabItemQuantity(
|
||||
tabItemId: await itemId(tab.id, 'beer'),
|
||||
delta: -10,
|
||||
);
|
||||
|
||||
expect(applied, -2);
|
||||
expect(await stockOf('beer'), _beer.stockQuantity);
|
||||
});
|
||||
|
||||
test('closing a tab does not touch stock', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _beer);
|
||||
|
||||
await tabs.closeTab(tab.id);
|
||||
|
||||
expect(await stockOf('beer'), _beer.stockQuantity - 1);
|
||||
});
|
||||
|
||||
test('infinite stock is never counted down or run out', () async {
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tabs.addProductToTab(tabId: tab.id, product: _water);
|
||||
}
|
||||
await tabs.deleteTab(tab.id);
|
||||
|
||||
expect(await stockOf('water'), 0);
|
||||
});
|
||||
|
||||
test('a failed add leaves no item behind', () async {
|
||||
await products.updateProduct(_wine.copyWith(stockQuantity: 0));
|
||||
final tab = await tabs.createTab(customerName: 'John');
|
||||
|
||||
await expectLater(
|
||||
tabs.addProductToTab(tabId: tab.id, product: _wine),
|
||||
throwsA(isA<InsufficientStockException>()),
|
||||
);
|
||||
|
||||
final open = (await tabs.getTabById(tab.id))!;
|
||||
expect(open.items, isEmpty);
|
||||
expect(open.totalInCents, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user