feat: add alot

This commit is contained in:
2026-10-08 04:12:36 +02:00
parent ccf5165417
commit fc607cb7bd
99 changed files with 4981 additions and 3655 deletions
+9 -41
View File
@@ -1,52 +1,20 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/services/admin_pin_service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:kooltap/services/admin_pin_service.dart';
class MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {}
import 'helpers/memory_secure_storage.dart';
void main() {
late AdminPinService service;
late MockFlutterSecureStorage mockStorage;
setUp(() {
mockStorage = MockFlutterSecureStorage();
service = AdminPinService(storage: mockStorage);
});
test('uses separate secure-storage keys for the admin PIN', () async {
when(
() => mockStorage.write(
key: any(named: 'key'),
value: any(named: 'value'),
),
).thenAnswer((_) async {});
final storage = MemorySecureStorage();
final service = AdminPinService(storage: storage);
await service.setPin('1234');
verify(
() => mockStorage.write(
key: 'admin_pin_salt',
value: any(named: 'value'),
),
).called(1);
verify(
() => mockStorage.write(
key: 'admin_pin_hash',
value: any(named: 'value'),
),
).called(1);
verifyNever(
() => mockStorage.write(
key: 'pin_salt',
value: any(named: 'value'),
),
);
verifyNever(
() => mockStorage.write(
key: 'pin_hash',
value: any(named: 'value'),
),
expect(
storage.values.keys,
containsAll(['admin_pin_salt', 'admin_pin_hash']),
);
expect(storage.values.keys, isNot(contains('pin_salt')));
expect(storage.values.keys, isNot(contains('pin_hash')));
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/app/app_lifecycle_lock.dart';
import 'package:kooltap/app/app_lifecycle_lock.dart';
void main() {
test('locks when the app is no longer active', () {
+94 -55
View File
@@ -1,39 +1,90 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/models/tab_item.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltab2/services/remote_sync_service.dart';
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
import 'package:kooltab2/viewmodels/remote_sync_view_model.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/models/tab_item.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/services/product_service.dart';
import 'package:kooltap/viewmodels/bar_screen_view_model.dart';
import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
class MockProductService extends Mock implements ProductService {}
class MockRemoteSyncService extends Mock implements RemoteSyncService {}
BarTab _tab(String id) {
return BarTab(
id: id,
customerName: id,
openedAt: DateTime(2026),
items: const [],
);
}
void main() {
late MockBarTabService barTabService;
late MockProductService productService;
late InventoryViewModel inventory;
late StreamController<List<BarTab>> openTabs;
late BarScreenViewModel viewModel;
late RemoteSyncViewModel remoteSyncViewModel;
setUp(() {
barTabService = MockBarTabService();
productService = MockProductService();
inventory = InventoryViewModel(productService: productService);
remoteSyncViewModel = RemoteSyncViewModel(
syncService: MockRemoteSyncService(),
);
viewModel = BarScreenViewModel(
barTabService: barTabService,
inventory: inventory,
remoteSyncViewModel: remoteSyncViewModel,
);
openTabs = StreamController<List<BarTab>>.broadcast();
when(
() => barTabService.watchOpenTabs(),
).thenAnswer((_) => openTabs.stream);
viewModel = BarScreenViewModel(barTabService: barTabService);
});
tearDown(() async {
viewModel.dispose();
await openTabs.close();
});
test('shows tabs from the stream and selects the first one', () async {
viewModel.ensureLoaded();
expect(viewModel.isLoading, true);
openTabs.add([_tab('a'), _tab('b')]);
await pumpEventQueue();
expect(viewModel.isLoading, false);
expect(viewModel.tabs.map((tab) => tab.id), ['a', 'b']);
expect(viewModel.selectedTabId, 'a');
});
test('keeps the selection while that tab still exists', () async {
viewModel.ensureLoaded();
openTabs.add([_tab('a'), _tab('b')]);
await pumpEventQueue();
viewModel.selectTab('b');
openTabs.add([_tab('c'), _tab('b')]);
await pumpEventQueue();
expect(viewModel.selectedTabId, 'b');
});
test(
'falls back to the first tab when the selected one disappears',
() async {
viewModel.ensureLoaded();
openTabs.add([_tab('a'), _tab('b')]);
await pumpEventQueue();
viewModel.selectTab('b');
openTabs.add([_tab('a')]);
await pumpEventQueue();
expect(viewModel.selectedTabId, 'a');
},
);
test('exposes a load error when the stream fails', () async {
viewModel.ensureLoaded();
openTabs.addError(StateError('database closed'));
await pumpEventQueue();
expect(viewModel.loadError, isNotNull);
expect(viewModel.isLoading, false);
});
test('propagates an insufficient-stock error from the transaction', () async {
@@ -55,41 +106,29 @@ void main() {
throwsA(isA<InsufficientStockException>()),
);
expect(viewModel.errorMessage, isNull);
verify(
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
).called(1);
expect(viewModel.loadError, isNull);
});
test('updates inventory when a tab is deleted', () async {
final product = Product(
id: 'prod-1',
name: 'Chips',
category: 'Snacks',
stockQuantity: 8,
lowStockThreshold: 2,
priceInCents: 150,
);
const item = TabItem(
id: 'item-1',
tabId: 'tab-1',
productId: 'prod-1',
productName: 'Chips',
quantity: 3,
unitPriceInCents: 150,
);
when(() => productService.getProducts()).thenAnswer((_) async => [product]);
await inventory.load();
test('selects a newly created tab', () async {
when(
() => barTabService.deleteTab('tab-1'),
).thenAnswer((_) async => [item]);
when(() => barTabService.getOpenTabs()).thenAnswer((_) async => []);
() => barTabService.createTab(customerName: 'New'),
).thenAnswer((_) async => _tab('new'));
await viewModel.deleteTab('tab-1');
await viewModel.createTab('New');
expect(inventory.products.first.stockQuantity, 11);
verify(() => barTabService.deleteTab('tab-1')).called(1);
verify(() => barTabService.getOpenTabs()).called(1);
expect(viewModel.selectedTabId, 'new');
});
test(
'rethrows failed actions without turning them into a load error',
() async {
when(
() => barTabService.deleteTab('tab-1'),
).thenAnswer((_) async => throw StateError('boom'));
await expectLater(viewModel.deleteTab('tab-1'), throwsStateError);
expect(viewModel.loadError, isNull);
},
);
}
+2 -55
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/bar_tab.dart';
import 'package:kooltab2/models/tab_item.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/models/tab_item.dart';
void main() {
group('BarTab', () {
@@ -9,7 +9,6 @@ void main() {
final tab = BarTab(
id: 'tab-1',
customerName: 'John',
status: 'open',
openedAt: DateTime.now(),
items: [
const TabItem(
@@ -38,7 +37,6 @@ void main() {
final tab = BarTab(
id: 'tab-1',
customerName: 'John',
status: 'open',
openedAt: DateTime.now(),
items: const [],
);
@@ -52,7 +50,6 @@ void main() {
final tab = BarTab(
id: 'tab-1',
customerName: 'John',
status: 'open',
openedAt: DateTime.now(),
items: [
const TabItem(
@@ -81,7 +78,6 @@ void main() {
final tab = BarTab(
id: 'tab-1',
customerName: 'John',
status: 'open',
openedAt: DateTime.now(),
items: const [],
);
@@ -89,54 +85,5 @@ void main() {
expect(tab.itemCount, 0);
});
});
group('formattedTotal', () {
test('formats total with euro symbol', () {
final tab = BarTab(
id: 'tab-1',
customerName: 'John',
status: 'open',
openedAt: DateTime.now(),
items: const [
TabItem(
id: 'item-1',
tabId: 'tab-1',
productId: 'prod-1',
productName: 'Beer',
quantity: 2,
unitPriceInCents: 425,
),
],
);
expect(tab.formattedTotal, '€8.50');
});
});
group('isOpen', () {
test('returns true for open status', () {
final tab = BarTab(
id: 'tab-1',
customerName: 'John',
status: 'open',
openedAt: DateTime.now(),
items: const [],
);
expect(tab.isOpen, true);
});
test('returns false for closed status', () {
final tab = BarTab(
id: 'tab-1',
customerName: 'John',
status: 'closed',
openedAt: DateTime.now(),
items: const [],
);
expect(tab.isOpen, false);
});
});
});
}
+131 -37
View File
@@ -1,10 +1,10 @@
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/services/product_service.dart';
void main() {
late AppDatabase database;
@@ -49,14 +49,21 @@ void main() {
group('DriftBarTabService', () {
group('createTab', () {
test('creates tab with open status', () async {
test('creates an empty tab at version 1', () async {
final tab = await service.createTab(customerName: 'John');
expect(tab.customerName, 'John');
expect(tab.status, 'open');
expect(tab.version, 1);
expect(tab.items, isEmpty);
});
test('rejects a blank customer name', () async {
await expectLater(
service.createTab(customerName: ' '),
throwsA(isA<ArgumentError>()),
);
});
test('creates tab with unique id', () async {
final tab1 = await service.createTab(customerName: 'John');
final tab2 = await service.createTab(customerName: 'Jane');
@@ -71,25 +78,6 @@ void main() {
expect(tabs, isEmpty);
});
test('returns only open tabs', () async {
await service.createTab(customerName: 'Open Tab');
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: 'closed-tab',
customerName: 'Closed Tab',
status: const Value('closed'),
openedAt: DateTime.now(),
),
);
final tabs = await service.getOpenTabs();
expect(tabs.length, 1);
expect(tabs.first.customerName, 'Open Tab');
});
test('tabs sorted by openedAt descending', () async {
final now = DateTime.now();
await database
@@ -98,7 +86,6 @@ void main() {
BarTabsCompanion.insert(
id: 'tab-first',
customerName: 'First',
status: const Value('open'),
openedAt: now.subtract(const Duration(hours: 1)),
),
);
@@ -108,7 +95,6 @@ void main() {
BarTabsCompanion.insert(
id: 'tab-second',
customerName: 'Second',
status: const Value('open'),
openedAt: now,
),
);
@@ -154,10 +140,8 @@ void main() {
await service.addProductToTab(tabId: tab.id, product: product);
await service.addProductToTab(tabId: tab.id, product: product);
final deletedItems = await service.deleteTab(tab.id);
await service.deleteTab(tab.id);
expect(deletedItems.length, 1);
expect(deletedItems.first.quantity, 2);
expect(await service.getTabById(tab.id), isNull);
final updatedProduct = await (database.select(
@@ -165,8 +149,8 @@ void main() {
)..where((row) => row.id.equals(product.id))).getSingle();
expect(updatedProduct.stockQuantity, 100);
final remainingItems = await database.select(database.tabItems).get();
expect(remainingItems, isEmpty);
expect(await database.select(database.tabItems).get(), isEmpty);
expect(await database.select(database.tabItemPurchases).get(), isEmpty);
});
});
@@ -412,16 +396,55 @@ void main() {
expect(updatedTab!.items, isEmpty);
});
test('tab remains open but items cleared', () async {
test(
'keeps the tab under the same name and drops its purchases',
() async {
final tab = await service.createTab(customerName: 'John');
final product = createTestProduct();
await service.addProductToTab(tabId: tab.id, product: product);
await service.closeTab(tab.id);
final updatedTab = await service.getTabById(tab.id);
expect(updatedTab!.customerName, 'John');
expect(updatedTab.items, isEmpty);
expect(
await database.select(database.tabItemPurchases).get(),
isEmpty,
);
},
);
test('throws when the tab changed since the expected version', () async {
final tab = await service.createTab(customerName: 'John');
final product = createTestProduct();
await service.addProductToTab(tabId: tab.id, product: product);
await service.closeTab(tab.id);
await expectLater(
service.closeTab(tab.id, expectedVersion: tab.version),
throwsA(isA<TabVersionConflictException>()),
);
final updatedTab = await service.getTabById(tab.id);
expect(updatedTab!.isOpen, true);
expect(updatedTab.items, isEmpty);
expect((await service.getTabById(tab.id))!.items, hasLength(1));
expect(await service.getClosedTabs(), isEmpty);
});
test('closes when the expected version matches', () async {
final tab = await service.createTab(customerName: 'John');
final product = createTestProduct();
await service.addProductToTab(tabId: tab.id, product: product);
final current = await service.getTabById(tab.id);
await service.closeTab(tab.id, expectedVersion: current!.version);
expect(await service.getClosedTabs(), hasLength(1));
});
test('throws for an unknown tab', () async {
await expectLater(
service.closeTab('missing'),
throwsA(isA<TabNotFoundException>()),
);
});
test('closed tab preserves item prices', () async {
@@ -436,6 +459,77 @@ void main() {
});
});
group('versioning', () {
test('every change bumps the tab version', () async {
final tab = await service.createTab(customerName: 'John');
final product = createTestProduct();
await service.addProductToTab(tabId: tab.id, product: product);
final afterAdd = (await service.getTabById(tab.id))!;
await service.adjustTabItemQuantity(
tabItemId: afterAdd.items.first.id,
delta: 1,
);
final afterAdjust = (await service.getTabById(tab.id))!;
await service.renameTab(tabId: tab.id, customerName: 'Jane');
final afterRename = (await service.getTabById(tab.id))!;
expect(afterAdd.version, tab.version + 1);
expect(afterAdjust.version, afterAdd.version + 1);
expect(afterRename.version, afterAdjust.version + 1);
});
test('a failed add leaves the version untouched', () async {
final tab = await service.createTab(customerName: 'John');
final product = createTestProduct();
await (database.update(database.products)
..where((row) => row.id.equals(product.id)))
.write(const ProductsCompanion(stockQuantity: Value(0)));
await expectLater(
service.addProductToTab(tabId: tab.id, product: product),
throwsA(isA<InsufficientStockException>()),
);
expect((await service.getTabById(tab.id))!.version, tab.version);
});
});
group('watchOpenTabs', () {
test('emits again when an item is added', () async {
final tab = await service.createTab(customerName: 'John');
final emissions = service.watchOpenTabs();
final expectation = expectLater(
emissions.map((tabs) => tabs.single.itemCount),
emitsThrough(1),
);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await expectation;
});
});
group('getDistinctCustomerNames', () {
test('returns each closed customer once, sorted', () async {
for (final name in ['Zoe', 'Adam', 'Zoe']) {
final tab = await service.createTab(customerName: name);
await service.addProductToTab(
tabId: tab.id,
product: createTestProduct(),
);
await service.closeTab(tab.id);
}
expect(await service.getDistinctCustomerNames(), ['Adam', 'Zoe']);
});
});
group('getClosedTabs', () {
test('returns empty when no closed tabs', () async {
final tabs = await service.getClosedTabs();
+78
View File
@@ -0,0 +1,78 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/database/app_database.dart';
/// The parts of the v12 schema that the v13 migration touches.
const _v12Schema = [
'CREATE TABLE bar_tabs (id TEXT NOT NULL, customer_name TEXT NOT NULL, '
"status TEXT NOT NULL DEFAULT 'open', opened_at INTEGER NOT NULL, "
'closed_at INTEGER NULL, version INTEGER NOT NULL DEFAULT 0, '
'PRIMARY KEY (id))',
'CREATE TABLE tab_items (id TEXT NOT NULL, tab_id TEXT NOT NULL, '
'product_id TEXT NOT NULL, product_name TEXT NOT NULL, '
'quantity INTEGER NOT NULL, unit_price_in_cents INTEGER NOT NULL, '
'created_at INTEGER NOT NULL, PRIMARY KEY (id))',
'CREATE TABLE tab_item_purchases (id TEXT NOT NULL, '
'tab_item_id TEXT NOT NULL, purchased_at INTEGER NOT NULL, '
'removed_at INTEGER NULL, PRIMARY KEY (id))',
"INSERT INTO bar_tabs VALUES ('tab-1', 'Alice', 'open', 1700000000, NULL, 4)",
"INSERT INTO tab_items VALUES ('item-1', 'tab-1', 'prod-1', 'Beer', 3, 400, "
'1700000100)',
"INSERT INTO tab_item_purchases VALUES ('p-1', 'item-1', 1700000200, NULL)",
"INSERT INTO tab_item_purchases VALUES ('p-2', 'item-1', 1700000300, "
'1700000400)',
"INSERT INTO tab_item_purchases VALUES ('orphan', 'gone', 1700000500, NULL)",
'PRAGMA user_version = 12',
];
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase(
NativeDatabase.memory(
setup: (rawDatabase) {
for (final statement in _v12Schema) {
rawDatabase.execute(statement);
}
},
),
);
});
tearDown(() async {
await database.close();
});
test('drops the unused status columns and keeps the tab', () async {
final tabs = await database.select(database.barTabs).get();
expect(tabs.single.customerName, 'Alice');
expect(tabs.single.version, 4);
final columns = await database
.customSelect('PRAGMA table_info(bar_tabs)')
.map((row) => row.read<String>('name'))
.get();
expect(columns, isNot(contains('status')));
expect(columns, isNot(contains('closed_at')));
});
test('backfills missing purchases up to the item quantity', () async {
final purchases = await (database.select(
database.tabItemPurchases,
)..where((row) => row.tabItemId.equals('item-1'))).get();
final active = purchases.where((row) => row.removedAt == null);
expect(active, hasLength(3));
expect(purchases.where((row) => row.removedAt != null), hasLength(1));
});
test('deletes purchases whose item no longer exists', () async {
final orphan = await (database.select(
database.tabItemPurchases,
)..where((row) => row.id.equals('orphan'))).getSingleOrNull();
expect(orphan, isNull);
});
}
+7 -7
View File
@@ -1,12 +1,12 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/pin_lock_service.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltab2/services/settings_service.dart';
import 'package:kooltab2/viewmodels/dev_menu_view_model.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/services/pin_lock_service.dart';
import 'package:kooltap/services/product_service.dart';
import 'package:kooltap/services/settings_service.dart';
import 'package:kooltap/viewmodels/dev_menu_view_model.dart';
import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
+6 -24
View File
@@ -1,41 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/l10n/app_localizations.dart';
import 'package:kooltab2/models/bar_tab.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltab2/services/remote_sync_service.dart';
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
import 'package:kooltab2/viewmodels/remote_sync_view_model.dart';
import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart';
import 'package:kooltap/l10n/app_localizations.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/services/bar_tab_service.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 MockProductService extends Mock implements ProductService {}
class MockRemoteSyncService extends Mock implements RemoteSyncService {}
void main() {
testWidgets('edit dialog disposes its controller after closing', (
tester,
) async {
final barTabService = MockBarTabService();
final productService = MockProductService();
final remoteSyncViewModel = RemoteSyncViewModel(
syncService: MockRemoteSyncService(),
);
final viewModel = BarScreenViewModel(
barTabService: barTabService,
inventory: InventoryViewModel(productService: productService),
remoteSyncViewModel: remoteSyncViewModel,
);
final viewModel = BarScreenViewModel(barTabService: barTabService);
final tab = BarTab(
id: 'tab-1',
customerName: 'Before',
status: 'open',
openedAt: DateTime(2026),
items: const [],
);
@@ -43,7 +26,6 @@ void main() {
when(
() => barTabService.renameTab(tabId: 'tab-1', customerName: 'After'),
).thenAnswer((_) async {});
when(() => barTabService.getOpenTabs()).thenAnswer((_) async => [tab]);
await tester.pumpWidget(
ChangeNotifierProvider<BarScreenViewModel>.value(
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
/// An in-memory stand-in for the platform keystore.
class MemorySecureStorage extends Fake implements FlutterSecureStorage {
final Map<String, String> values = {};
@override
Future<String?> read({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => values[key];
@override
Future<void> write({
required String key,
required String? value,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (value == null) {
values.remove(key);
} else {
values[key] = value;
}
}
@override
Future<void> delete({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
values.remove(key);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/closed_tab_item.dart';
import 'package:kooltab2/utils/history_grouping.dart';
import 'package:kooltap/models/closed_tab_item.dart';
import 'package:kooltap/utils/history_grouping.dart';
void main() {
test('groups items by purchase day with newest day first', () {
+6 -13
View File
@@ -1,9 +1,9 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/bar_tab.dart';
import 'package:kooltab2/models/closed_tab.dart';
import 'package:kooltab2/models/payment_method.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/viewmodels/history_view_model.dart';
import 'package:kooltap/models/bar_tab.dart';
import 'package:kooltap/models/closed_tab.dart';
import 'package:kooltap/models/payment_method.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/viewmodels/history_view_model.dart';
import 'package:mocktail/mocktail.dart';
class MockBarTabService extends Mock implements BarTabService {}
@@ -90,19 +90,12 @@ ClosedTab _closedTab(String id, DateTime closedAt) {
}
BarTab _barTab(String id, DateTime openedAt) {
return BarTab(
id: id,
customerName: id,
status: 'open',
openedAt: openedAt,
items: const [],
);
return BarTab(id: id, customerName: id, openedAt: openedAt, items: const []);
}
final _openTab = BarTab(
id: 'tab-1',
customerName: 'Alice',
status: 'open',
openedAt: DateTime(2026, 8, 2, 12),
items: const [],
);
+57 -125
View File
@@ -1,157 +1,89 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/services/product_service.dart';
import 'package:kooltap/viewmodels/inventory_view_model.dart';
import 'package:mocktail/mocktail.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
class MockProductService extends Mock implements ProductService {}
void main() {
late InventoryViewModel viewModel;
late MockProductService mockService;
late StreamController<List<Product>> products;
late InventoryViewModel viewModel;
const beer = Product(
id: 'prod-1',
name: 'Beer',
category: 'Drinks',
stockQuantity: 20,
lowStockThreshold: 5,
priceInCents: 400,
);
setUp(() {
mockService = MockProductService();
products = StreamController<List<Product>>.broadcast();
when(() => mockService.watchProducts()).thenAnswer((_) => products.stream);
viewModel = InventoryViewModel(productService: mockService);
});
setUpAll(() {
registerFallbackValue(const Product(
id: 'fallback',
name: 'Fallback',
category: 'Test',
stockQuantity: 0,
lowStockThreshold: 0,
priceInCents: 0,
));
tearDown(() async {
viewModel.dispose();
await products.close();
});
final testProducts = [
const Product(
id: 'prod-1',
name: 'Beer',
category: 'Drinks',
stockQuantity: 20,
lowStockThreshold: 5,
priceInCents: 400,
),
const Product(
id: 'prod-2',
name: 'Wine',
category: 'Drinks',
stockQuantity: 3,
lowStockThreshold: 5,
priceInCents: 600,
),
];
group('InventoryViewModel', () {
group('load', () {
test('loads products from service', () async {
when(() => mockService.getProducts())
.thenAnswer((_) async => testProducts);
test('is loading until the first emission', () async {
viewModel.ensureLoaded();
expect(viewModel.isLoading, true);
await viewModel.load();
products.add([beer]);
await pumpEventQueue();
expect(viewModel.products, testProducts);
verify(() => mockService.getProducts()).called(1);
});
test('notifies listeners after load', () async {
var notifyCount = 0;
viewModel.addListener(() => notifyCount++);
when(() => mockService.getProducts())
.thenAnswer((_) async => testProducts);
await viewModel.load();
expect(notifyCount, 1);
});
expect(viewModel.isLoading, false);
expect(viewModel.products, [beer]);
});
group('decreaseStock', () {
test('decreases stock via service', () async {
when(() => mockService.decreaseStock('prod-1', 3))
.thenAnswer((_) async {});
test('follows stock changes from the database', () async {
viewModel.ensureLoaded();
products.add([beer]);
await pumpEventQueue();
await viewModel.decreaseStock('prod-1', 3);
products.add([beer.copyWith(stockQuantity: 19)]);
await pumpEventQueue();
verify(() => mockService.decreaseStock('prod-1', 3)).called(1);
});
test('updates local product cache', () async {
when(() => mockService.getProducts())
.thenAnswer((_) async => testProducts);
when(() => mockService.decreaseStock('prod-1', 5))
.thenAnswer((_) async {});
await viewModel.load();
await viewModel.decreaseStock('prod-1', 5);
final product = viewModel.products.firstWhere((p) => p.id == 'prod-1');
expect(product.stockQuantity, 15);
});
test('notifies listeners after decrease', () async {
var notifyCount = 0;
viewModel.addListener(() => notifyCount++);
when(() => mockService.decreaseStock('prod-1', 1))
.thenAnswer((_) async {});
await viewModel.decreaseStock('prod-1', 1);
expect(notifyCount, 1);
});
expect(viewModel.products.single.stockQuantity, 19);
});
group('increaseStock', () {
test('increases stock via service', () async {
when(() => mockService.increaseStock('prod-1', 5))
.thenAnswer((_) async {});
test('subscribes only once', () async {
viewModel.ensureLoaded();
viewModel.ensureLoaded();
await viewModel.increaseStock('prod-1', 5);
verify(() => mockService.increaseStock('prod-1', 5)).called(1);
});
test('updates local product cache', () async {
when(() => mockService.getProducts())
.thenAnswer((_) async => testProducts);
when(() => mockService.increaseStock('prod-2', 2))
.thenAnswer((_) async {});
await viewModel.load();
await viewModel.increaseStock('prod-2', 2);
final product = viewModel.products.firstWhere((p) => p.id == 'prod-2');
expect(product.stockQuantity, 5);
});
test('notifies listeners after increase', () async {
var notifyCount = 0;
viewModel.addListener(() => notifyCount++);
when(() => mockService.increaseStock('prod-1', 1))
.thenAnswer((_) async {});
await viewModel.increaseStock('prod-1', 1);
expect(notifyCount, 1);
});
verify(() => mockService.watchProducts()).called(1);
});
group('edge cases', () {
test('handles decrease for product not in local cache', () async {
when(() => mockService.getProducts())
.thenAnswer((_) async => testProducts);
when(() => mockService.decreaseStock('unknown', 1))
.thenAnswer((_) async {});
test('notifies listeners on every emission', () async {
var notifyCount = 0;
viewModel.addListener(() => notifyCount++);
viewModel.ensureLoaded();
await viewModel.load();
await viewModel.decreaseStock('unknown', 1);
products
..add([beer])
..add([]);
await pumpEventQueue();
verify(() => mockService.decreaseStock('unknown', 1)).called(1);
expect(viewModel.products.length, 2);
});
expect(notifyCount, 2);
});
test('exposes a load error when the stream fails', () async {
viewModel.ensureLoaded();
products.addError(StateError('database closed'));
await pumpEventQueue();
expect(viewModel.loadError, isNotNull);
});
});
}
+92
View File
@@ -0,0 +1,92 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:kooltap/models/remote_command.dart';
import 'package:kooltap/services/kooltap_api_client.dart';
KoolTapApiClient _clientReturning(http.Response response) {
return KoolTapApiClient(
client: MockClient((_) async => response),
baseUrl: 'https://api.test',
);
}
http.Response _error(int status, String code, String message) {
return http.Response(
jsonEncode({
'error': {'code': code, 'message': message},
}),
status,
);
}
void main() {
group('registerDevice', () {
test('surfaces the server reason for a rejected code', () async {
final client = _clientReturning(
_error(
401,
'invalid_registration_code',
'Invalid or expired registration code',
),
);
await expectLater(
client.registerDevice(
venueId: 'venue',
deviceName: 'Bar',
registrationCode: 'WRONG',
),
throwsA(
isA<ApiException>()
.having((e) => e, 'type', isNot(isA<UnauthorizedException>()))
.having((e) => e.code, 'code', 'invalid_registration_code')
.having(
(e) => e.message,
'message',
'Invalid or expired registration code',
),
),
);
});
test('falls back to the reason phrase for a non-JSON error', () async {
final client = _clientReturning(
http.Response('<html>', 502, reasonPhrase: 'Bad Gateway'),
);
await expectLater(
client.registerDevice(
venueId: 'venue',
deviceName: 'Bar',
registrationCode: 'CODE',
),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'Bad Gateway',
),
),
);
});
});
group('sync', () {
test('treats a 401 as a revoked device token', () async {
final client = _clientReturning(
_error(401, 'invalid_token', 'Token revoked'),
);
await expectLater(
client.sync(
token: 'token',
request: const SyncRequest(deviceId: 'device', openTabs: []),
),
throwsA(isA<UnauthorizedException>()),
);
});
});
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/utils/money.dart';
void main() {
group('Cents.asEuro', () {
test('formats cents in Belgian notation', () {
expect(1234.asEuro, '€ 12,34');
});
test('formats zero', () {
expect(0.asEuro, '€ 0,00');
});
test('formats whole euros with two decimals', () {
expect(500.asEuro, '€ 5,00');
});
test('groups thousands', () {
expect(123456.asEuro, '€ 1.234,56');
});
});
}
+101 -83
View File
@@ -1,149 +1,167 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:kooltab2/services/pin_lock_service.dart';
import 'package:kooltap/services/pin_lock_service.dart';
class MockFlutterSecureStorage extends Mock
implements FlutterSecureStorage {}
import 'helpers/memory_secure_storage.dart';
String _computeHash(String pin, String salt) {
final bytes = utf8.encode('$salt:$pin');
return sha256.convert(bytes).toString();
String _legacyHash(String pin, String salt) {
return sha256.convert(utf8.encode('$salt:$pin')).toString();
}
void main() {
late MemorySecureStorage storage;
late DateTime now;
late PinLockService service;
late MockFlutterSecureStorage mockStorage;
setUp(() {
mockStorage = MockFlutterSecureStorage();
service = PinLockService(storage: mockStorage);
storage = MemorySecureStorage();
now = DateTime(2026, 10, 7, 12);
service = PinLockService(storage: storage, clock: () => now);
});
Future<void> failTimes(int count) async {
for (var attempt = 0; attempt < count; attempt++) {
await service.verifyPin('0000');
}
}
group('PinLockService', () {
group('hasPin', () {
test('returns false when no hash stored', () async {
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => null);
final result = await service.hasPin();
expect(result, false);
expect(await service.hasPin(), false);
});
test('returns false when hash is empty', () async {
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => '');
storage.values['pin_hash'] = '';
final result = await service.hasPin();
expect(result, false);
expect(await service.hasPin(), false);
});
test('returns true when hash exists', () async {
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => 'somehash');
test('returns true once a pin is set', () async {
await service.setPin('1234');
final result = await service.hasPin();
expect(result, true);
expect(await service.hasPin(), true);
});
});
group('setPin', () {
test('stores salt and hash', () async {
when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value')))
.thenAnswer((_) async {});
test('stores a salted PBKDF2 hash', () async {
await service.setPin('1234');
verify(() => mockStorage.write(key: 'pin_salt', value: any(named: 'value'))).called(1);
verify(() => mockStorage.write(key: 'pin_hash', value: any(named: 'value'))).called(1);
expect(storage.values['pin_salt'], isNotEmpty);
expect(storage.values['pin_hash'], startsWith(r'pbkdf2-sha256$'));
expect(storage.values['pin_hash'], isNot(contains('1234')));
});
test('generates unique salts for different pins', () async {
when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value')))
.thenAnswer((_) async {});
await service.setPin('1234');
final firstSaltCapture = verify(() => mockStorage.write(
key: 'pin_salt',
value: captureAny(named: 'value'),
)).captured.first;
final firstSalt = storage.values['pin_salt'];
await service.setPin('5678');
final secondSaltCapture = verify(() => mockStorage.write(
key: 'pin_salt',
value: captureAny(named: 'value'),
)).captured.last;
expect(firstSaltCapture, isNot(equals(secondSaltCapture)));
expect(storage.values['pin_salt'], isNot(firstSalt));
});
});
group('verifyPin', () {
test('returns false when salt is missing', () async {
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => null);
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => 'somehash');
storage.values['pin_hash'] = 'somehash';
final result = await service.verifyPin('1234');
expect(result, false);
expect(await service.verifyPin('1234'), false);
});
test('returns false when hash is missing', () async {
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => 'somesalt');
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => null);
storage.values['pin_salt'] = 'somesalt';
final result = await service.verifyPin('1234');
expect(result, false);
expect(await service.verifyPin('1234'), false);
});
test('returns true for correct pin', () async {
const salt = 'test_salt';
const pin = '1234';
final expectedHash = _computeHash(pin, salt);
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => salt);
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => expectedHash);
await service.setPin('1234');
final result = await service.verifyPin('1234');
expect(result, true);
expect(await service.verifyPin('1234'), true);
});
test('returns false for incorrect pin', () async {
const salt = 'test_salt';
final correctHash = _computeHash('1234', salt);
when(() => mockStorage.read(key: 'pin_salt'))
.thenAnswer((_) async => salt);
when(() => mockStorage.read(key: 'pin_hash'))
.thenAnswer((_) async => correctHash);
await service.setPin('1234');
final result = await service.verifyPin('5678');
expect(await service.verifyPin('5678'), false);
});
expect(result, false);
test('accepts a legacy hash and upgrades it', () async {
storage.values['pin_salt'] = 'test_salt';
storage.values['pin_hash'] = _legacyHash('1234', 'test_salt');
expect(await service.verifyPin('1234'), true);
expect(storage.values['pin_hash'], startsWith(r'pbkdf2-sha256$'));
expect(await service.verifyPin('1234'), true);
});
test('rejects a wrong pin against a legacy hash', () async {
storage.values['pin_salt'] = 'test_salt';
storage.values['pin_hash'] = _legacyHash('1234', 'test_salt');
expect(await service.verifyPin('5678'), false);
expect(storage.values['pin_hash'], _legacyHash('1234', 'test_salt'));
});
});
group('lockout', () {
test('locks after five wrong attempts', () async {
await service.setPin('1234');
await failTimes(5);
await expectLater(
service.verifyPin('1234'),
throwsA(isA<PinLockedOutException>()),
);
});
test('unlocks once the lockout has passed', () async {
await service.setPin('1234');
await failTimes(5);
now = now.add(const Duration(seconds: 31));
expect(await service.verifyPin('1234'), true);
});
test('doubles the lockout for every further failure', () async {
await service.setPin('1234');
await failTimes(5);
now = now.add(const Duration(seconds: 31));
await failTimes(1);
now = now.add(const Duration(seconds: 31));
await expectLater(
service.verifyPin('1234'),
throwsA(isA<PinLockedOutException>()),
);
now = now.add(const Duration(seconds: 30));
expect(await service.verifyPin('1234'), true);
});
test('a correct pin resets the failure count', () async {
await service.setPin('1234');
await failTimes(4);
await service.verifyPin('1234');
await failTimes(4);
expect(await service.verifyPin('1234'), true);
});
});
group('clearPin', () {
test('deletes both salt and hash', () async {
when(() => mockStorage.delete(key: any(named: 'key')))
.thenAnswer((_) async {});
test('deletes the pin and any lockout', () async {
await service.setPin('1234');
await failTimes(5);
await service.clearPin();
verify(() => mockStorage.delete(key: 'pin_salt')).called(1);
verify(() => mockStorage.delete(key: 'pin_hash')).called(1);
expect(storage.values, isEmpty);
});
});
});
+1 -42
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltap/models/product.dart';
void main() {
group('Product', () {
@@ -44,47 +44,6 @@ void main() {
});
});
group('formattedPrice', () {
test('formats price with euro symbol and 2 decimals', () {
const product = Product(
id: '1',
name: 'Test',
category: 'Drinks',
stockQuantity: 10,
lowStockThreshold: 5,
priceInCents: 1234,
);
expect(product.formattedPrice, '€12.34');
});
test('formats zero price correctly', () {
const product = Product(
id: '1',
name: 'Test',
category: 'Drinks',
stockQuantity: 10,
lowStockThreshold: 5,
priceInCents: 0,
);
expect(product.formattedPrice, '€0.00');
});
test('formats whole euros correctly', () {
const product = Product(
id: '1',
name: 'Test',
category: 'Drinks',
stockQuantity: 10,
lowStockThreshold: 5,
priceInCents: 500,
);
expect(product.formattedPrice, '€5.00');
});
});
group('copyWith', () {
test('creates copy with updated fields', () {
const original = Product(
+3 -3
View File
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:kooltab2/l10n/app_localizations.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/views/widgets/product_tile.dart';
import 'package:kooltap/l10n/app_localizations.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/views/widgets/product_tile.dart';
void main() {
testWidgets('long-pressing a product tile returns to the bar after edit', (
+3 -3
View File
@@ -1,9 +1,9 @@
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/services/product_service.dart';
void main() {
late AppDatabase database;
+403
View File
@@ -0,0 +1,403 @@
import 'dart:async';
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/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/remote_sync_service.dart';
import 'helpers/memory_secure_storage.dart';
const _baseUrl = 'https://api.test';
const _beer = Product(
id: 'prod-1',
name: 'Beer',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 400,
);
/// A scripted server: [syncResponse] answers `/sync`, acks are recorded.
class _FakeServer {
http.Response syncResponse = http.Response('{}', 200);
final List<Map<String, dynamic>> syncRequests = [];
final List<Map<String, dynamic>> acks = [];
/// While set, `/sync` responses wait for it to complete.
Completer<void>? holdSync;
late final client = MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
if (request.url.path.endsWith('/sync')) {
syncRequests.add(body);
await holdSync?.future;
return syncResponse;
}
if (request.url.path.endsWith('/ack')) {
acks.add(body);
return http.Response('', 200);
}
return http.Response('not found', 404);
});
void respondWith(Map<String, dynamic> json) {
syncResponse = http.Response(jsonEncode(json), 200);
}
}
Map<String, dynamic> _closeCommand({
required String id,
required String tabId,
required int expectedVersion,
String type = 'close_tab',
DateTime? createdAt,
}) {
return {
'id': id,
'type': type,
'tabId': tabId,
'expectedVersion': expectedVersion,
'paymentMethod': 'payconiq',
'requestedBy': 'Dashboard',
'createdAt': (createdAt ?? DateTime.now()).toIso8601String(),
};
}
void main() {
late AppDatabase database;
late DriftBarTabService barTabService;
late MemorySecureStorage storage;
late _FakeServer server;
late RemoteSyncService service;
late List<RemoteSyncEvent> events;
setUp(() async {
database = AppDatabase(NativeDatabase.memory());
barTabService = DriftBarTabService(database: database);
storage = MemorySecureStorage();
server = _FakeServer();
service = RemoteSyncService(
database: database,
barTabService: barTabService,
apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl),
secureStorage: storage,
);
await service.setCredentials(
deviceId: 'device-1',
token: 'token',
venueId: 'venue-1',
);
events = [];
service.events.listen(events.add);
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 {
service.dispose();
await database.close();
});
List<RemoteSyncEventType> eventTypes() => [
for (final event in events) event.type,
];
group('sync', () {
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);
server.respondWith({'cursor': 'cursor-1'});
await service.sync();
await pumpEventQueue();
final snapshot = server.syncRequests.single['openTabs'] as List;
expect(snapshot.single['customerName'], 'Alice');
expect(snapshot.single['totalInCents'], 400);
expect(eventTypes(), [RemoteSyncEventType.syncCompleted]);
expect(await service.lastSuccessfulSyncAt, isNotNull);
await service.sync();
expect(server.syncRequests.last['cursor'], 'cursor-1');
});
test('reports a server error as a failure, not a success', () async {
server.syncResponse = http.Response('oops', 500);
await service.sync();
await pumpEventQueue();
expect(eventTypes(), [RemoteSyncEventType.syncFailed]);
expect(await service.lastSuccessfulSyncAt, isNull);
final state = await database.select(database.remoteSyncState).getSingle();
expect(state.consecutiveFailureCount, 1);
});
test('reports rate limiting as a failure', () async {
server.syncResponse = http.Response(
'',
429,
headers: {'retry-after': '120'},
);
await service.sync();
await pumpEventQueue();
expect(eventTypes(), [RemoteSyncEventType.syncFailed]);
});
test('drops the token and reports when it was revoked', () async {
server.syncResponse = http.Response('', 401);
await service.sync();
await pumpEventQueue();
expect(eventTypes(), [RemoteSyncEventType.unauthorized]);
expect(await service.isConfigured, false);
});
test('creates planned tabs that are not open yet, once', () async {
await barTabService.createTab(customerName: 'Alice');
server.respondWith({
'plannedTabNames': ['Alice', 'Bob', 'Bob', ' '],
});
await service.sync();
final names = (await barTabService.getOpenTabs()).map(
(tab) => tab.customerName,
);
expect(names, unorderedEquals(['Alice', 'Bob']));
});
});
group('close_tab commands', () {
test('closes the tab when the version matches', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
final current = (await barTabService.getTabById(tab.id))!;
server.respondWith({
'commands': [
_closeCommand(
id: 'cmd-1',
tabId: tab.id,
expectedVersion: current.version,
),
],
});
await service.sync();
await pumpEventQueue();
final closed = await barTabService.getClosedTabs();
expect(closed.single.customerName, 'Alice');
expect(closed.single.paymentMethod.value, 'payconiq');
expect(server.acks.single['status'], 'completed');
expect(eventTypes(), contains(RemoteSyncEventType.tabClosedRemotely));
});
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);
server.respondWith({
'commands': [
_closeCommand(id: 'cmd-1', tabId: tab.id, expectedVersion: 1),
],
});
await service.sync();
await pumpEventQueue();
expect(await barTabService.getClosedTabs(), isEmpty);
expect((await barTabService.getTabById(tab.id))!.items, hasLength(1));
expect(server.acks.single['status'], 'conflict');
expect(eventTypes(), contains(RemoteSyncEventType.conflict));
});
test('reports a conflict for an unknown tab', () async {
server.respondWith({
'commands': [
_closeCommand(id: 'cmd-1', tabId: 'missing', expectedVersion: 1),
],
});
await service.sync();
expect(server.acks.single['status'], 'conflict');
});
test('expires commands older than the expiry window', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
server.respondWith({
'commands': [
_closeCommand(
id: 'cmd-1',
tabId: tab.id,
expectedVersion: tab.version,
createdAt: DateTime.now().subtract(const Duration(days: 2)),
),
],
});
await service.sync();
expect(server.acks.single['status'], 'expired');
});
test('fails commands of an unknown type', () async {
server.respondWith({
'commands': [
_closeCommand(
id: 'cmd-1',
tabId: 'tab',
expectedVersion: 1,
type: 'refund',
),
],
});
await service.sync();
expect(server.acks.single['status'], 'failed');
});
test(
're-acks an already processed command without re-running it',
() async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
final current = (await barTabService.getTabById(tab.id))!;
server.respondWith({
'commands': [
_closeCommand(
id: 'cmd-1',
tabId: tab.id,
expectedVersion: current.version,
),
],
});
await service.sync();
await service.sync();
expect(await barTabService.getClosedTabs(), hasLength(1));
expect(server.acks.map((ack) => ack['status']), [
'completed',
'completed',
]);
final activity = await service.getRecentActivity();
expect(activity.single.result, CommandResult.completed);
},
);
});
group('sync after tab changes', () {
late RemoteSyncService fast;
setUp(() {
fast = RemoteSyncService(
database: database,
barTabService: barTabService,
apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl),
secureStorage: storage,
changeDebounce: const Duration(milliseconds: 50),
changeMaxDelay: const Duration(milliseconds: 300),
);
server.respondWith({'cursor': 'cursor-1'});
});
tearDown(() => fast.dispose());
Future<void> wait(int ms) =>
Future<void>.delayed(Duration(milliseconds: ms));
int syncedTotal() =>
(server.syncRequests.last['openTabs'] as List).single['totalInCents']
as int;
test('syncs shortly after a tab changes', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
fast.startPeriodicSync();
await wait(150);
expect(server.syncRequests, isEmpty, reason: 'starting is not a change');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
await wait(250);
expect(server.syncRequests, hasLength(1));
expect(syncedTotal(), 400);
});
test('bundles a burst of changes into one sync', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
fast.startPeriodicSync();
await wait(150);
for (var i = 0; i < 5; i++) {
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
}
await wait(250);
expect(server.syncRequests, hasLength(1));
expect(syncedTotal(), 2000);
});
test('does not sync after changes once stopped', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
fast.startPeriodicSync();
await wait(150);
fast.stopPeriodicSync();
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
await wait(250);
expect(server.syncRequests, isEmpty);
});
test(
'a sync requested during a running sync runs once afterwards',
() async {
final tab = await barTabService.createTab(customerName: 'Alice');
server.holdSync = Completer<void>();
final first = fast.sync();
await wait(50);
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
unawaited(fast.sync());
unawaited(fast.sync());
server.holdSync!.complete();
server.holdSync = null;
await first;
await wait(150);
expect(server.syncRequests, hasLength(2));
expect(syncedTotal(), 400, reason: 'the follow-up carries the change');
},
);
});
}
+3 -3
View File
@@ -1,8 +1,8 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/models/settings.dart';
import 'package:kooltab2/services/settings_service.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/models/settings.dart';
import 'package:kooltap/services/settings_service.dart';
void main() {
late AppDatabase database;