feat: add alot
This commit is contained in:
@@ -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,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', () {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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 [],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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,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(
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user