diff --git a/lib/app/app.dart b/lib/app/app.dart index a6300e0..0caecbb 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import 'app_lifecycle_lock.dart'; import '../models/settings.dart'; +import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/settings_view_model.dart'; import '../theme.dart'; import '../l10n/app_localizations.dart'; @@ -17,15 +19,28 @@ class KoolTabApp extends StatefulWidget { } class _KoolTabAppState extends State { + late final AppLifecycleLockObserver _lifecycleLockObserver; + @override void initState() { super.initState(); + _lifecycleLockObserver = AppLifecycleLockObserver( + onLock: () => context.read().lock(), + ); + WidgetsBinding.instance.addObserver(_lifecycleLockObserver); + WidgetsBinding.instance.addPostFrameCallback((_) { context.read().ensureLoaded(); }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(_lifecycleLockObserver); + super.dispose(); + } + @override Widget build(BuildContext context) { final settings = context.watch().settings; diff --git a/lib/app/app_lifecycle_lock.dart b/lib/app/app_lifecycle_lock.dart new file mode 100644 index 0000000..b86ca06 --- /dev/null +++ b/lib/app/app_lifecycle_lock.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class AppLifecycleLockObserver extends WidgetsBindingObserver { + final VoidCallback onLock; + + AppLifecycleLockObserver({required this.onLock}); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + onLock(); + case AppLifecycleState.resumed: + break; + } + } +} diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 1121454..82b3811 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -8,6 +8,7 @@ import '../models/closed_tab_item.dart'; import '../models/payment_method.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; +import 'product_service.dart'; abstract class BarTabService { Future> getOpenTabs(); @@ -19,18 +20,19 @@ abstract class BarTabService { Future addProductToTab({ required String tabId, required Product product, - Future Function()? stockAdjustment, }); - Future updateTabItemQuantity({ + Future adjustTabItemQuantity({ required String tabItemId, - required int quantity, - Future Function()? stockAdjustment, + required int delta, }); /// Archives the tab's current items into history and clears them. /// The tab itself stays open under the same customer name. - Future closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}); + Future closeTab( + String tabId, { + PaymentMethod paymentMethod = PaymentMethod.cash, + }); Future> getClosedTabs(); @@ -94,6 +96,54 @@ class DriftBarTabService implements BarTabService { return rows.map(_mapItemRow).toList(); } + Future _decreaseProductStock(String productId, int amount) async { + if (amount <= 0) { + throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); + } + + final updatedRows = await database.customUpdate( + 'UPDATE products ' + 'SET stock_quantity = stock_quantity - ? ' + 'WHERE id = ? AND stock_quantity >= ?', + variables: [ + Variable.withInt(amount), + Variable.withString(productId), + Variable.withInt(amount), + ], + updates: {database.products}, + ); + + if (updatedRows == 1) return; + + final product = await (database.select( + database.products, + )..where((row) => row.id.equals(productId))).getSingleOrNull(); + + if (product == null) { + throw Exception('Product not found'); + } + + throw const InsufficientStockException(); + } + + Future _increaseProductStock(String productId, int amount) async { + if (amount <= 0) { + throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); + } + + final updatedRows = await database.customUpdate( + 'UPDATE products ' + 'SET stock_quantity = stock_quantity + ? ' + 'WHERE id = ?', + variables: [Variable.withInt(amount), Variable.withString(productId)], + updates: {database.products}, + ); + + if (updatedRows == 1) return; + + throw Exception('Product not found'); + } + @override Future> getOpenTabs() async { final query = database.select(database.barTabs) @@ -157,9 +207,10 @@ class DriftBarTabService implements BarTabService { Future addProductToTab({ required String tabId, required Product product, - Future Function()? stockAdjustment, }) async { await database.transaction(() async { + await _decreaseProductStock(product.id, 1); + final existingItemQuery = database.select(database.tabItems) ..where( (item) => @@ -176,10 +227,6 @@ class DriftBarTabService implements BarTabService { TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); - if (stockAdjustment != null) { - await stockAdjustment(); - } - return; } @@ -196,46 +243,60 @@ class DriftBarTabService implements BarTabService { createdAt: DateTime.now(), ), ); - - if (stockAdjustment != null) { - await stockAdjustment(); - } }); } @override - Future updateTabItemQuantity({ + Future adjustTabItemQuantity({ required String tabItemId, - required int quantity, - Future Function()? stockAdjustment, + required int delta, }) async { - await database.transaction(() async { - if (quantity <= 0) { - final deleteQuery = database.delete(database.tabItems) - ..where((item) => item.id.equals(tabItemId)); + return database.transaction(() async { + final item = await (database.select( + database.tabItems, + )..where((row) => row.id.equals(tabItemId))).getSingleOrNull(); - await deleteQuery.go(); + if (item == null || delta == 0) return 0; - if (stockAdjustment != null) { - await stockAdjustment(); + final requestedQuantity = item.quantity + delta; + final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta; + + if (actualDelta > 0) { + await _decreaseProductStock(item.productId, actualDelta); + } else { + await _increaseProductStock(item.productId, -actualDelta); + } + + if (requestedQuantity <= 0) { + final deletedRows = await (database.delete( + database.tabItems, + )..where((row) => row.id.equals(tabItemId))).go(); + + if (deletedRows != 1) { + throw StateError('Tab item was changed before it could be deleted'); } + } else { + final updatedRows = + await (database.update(database.tabItems) + ..where((row) => row.id.equals(tabItemId))) + .write(TabItemsCompanion(quantity: Value(requestedQuantity))); - return; + if (updatedRows != 1) { + throw StateError( + 'Tab item was changed before its quantity was updated', + ); + } } - final updateQuery = database.update(database.tabItems) - ..where((item) => item.id.equals(tabItemId)); - - await updateQuery.write(TabItemsCompanion(quantity: Value(quantity))); - - if (stockAdjustment != null) { - await stockAdjustment(); - } + return actualDelta; }); } @override - Future closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}) async { + Future closeTab( + String tabId, { + PaymentMethod paymentMethod = PaymentMethod.cash, + }) async { await database.transaction(() async { final tabQuery = database.select(database.barTabs) ..where((tab) => tab.id.equals(tabId)); @@ -307,8 +368,7 @@ class DriftBarTabService implements BarTabService { ..limit(limit, offset: offset); if (customerName != null && customerName.isNotEmpty) { - query = query - ..where((tab) => tab.customerName.equals(customerName)); + query = query..where((tab) => tab.customerName.equals(customerName)); } final closedTabRows = await query.get(); @@ -355,10 +415,6 @@ class DriftBarTabService implements BarTabService { @override Future> getDistinctCustomerNames() async { final rows = await database.select(database.closedTabs).get(); - return rows - .map((row) => row.customerName) - .toSet() - .toList() - ..sort(); + return rows.map((row) => row.customerName).toSet().toList()..sort(); } } diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index 26fa8a0..f4b56ce 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -130,31 +130,48 @@ class DriftProductService implements ProductService { @override Future decreaseStock(String productId, int amount) async { - final product = await getProductById(productId); + _validateStockAmount(amount); - if (product == null) { - throw Exception('Product not found'); - } - - if (product.stockQuantity < amount) { - throw const InsufficientStockException(); - } - - await updateProduct( - product.copyWith(stockQuantity: product.stockQuantity - amount), + final updatedRows = await database.customUpdate( + 'UPDATE products ' + 'SET stock_quantity = stock_quantity - ? ' + 'WHERE id = ? AND stock_quantity >= ?', + variables: [ + Variable.withInt(amount), + Variable.withString(productId), + Variable.withInt(amount), + ], + updates: {database.products}, ); + + if (updatedRows == 1) return; + + final product = await getProductById(productId); + if (product == null) throw Exception('Product not found'); + + throw const InsufficientStockException(); } @override Future increaseStock(String productId, int amount) async { - final product = await getProductById(productId); + _validateStockAmount(amount); - if (product == null) { - throw Exception('Product not found'); - } - - await updateProduct( - product.copyWith(stockQuantity: product.stockQuantity + amount), + final updatedRows = await database.customUpdate( + 'UPDATE products ' + 'SET stock_quantity = stock_quantity + ? ' + 'WHERE id = ?', + variables: [Variable.withInt(amount), Variable.withString(productId)], + updates: {database.products}, ); + + if (updatedRows == 1) return; + + throw Exception('Product not found'); + } + + void _validateStockAmount(int amount) { + if (amount <= 0) { + throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); + } } } diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index 8746898..e8328f2 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -95,16 +95,9 @@ class BarScreenViewModel extends ChangeNotifier { if (tab == null) return; - if (product.stockQuantity <= 0) { - throw const InsufficientStockException(); - } - try { - await barTabService.addProductToTab( - tabId: tab.id, - product: product, - stockAdjustment: () => inventory.decreaseStock(product.id, 1), - ); + await barTabService.addProductToTab(tabId: tab.id, product: product); + inventory.applyStockDelta(product.id, -1); await _reloadTabs(); } catch (e, stack) { @@ -118,35 +111,15 @@ class BarScreenViewModel extends ChangeNotifier { } } - Future changeItemQuantity(TabItem item, int quantity) async { - final difference = quantity - item.quantity; - - if (difference > 0) { - Product? product; - for (final candidate in inventory.products) { - if (candidate.id == item.productId) { - product = candidate; - break; - } - } - - if (product != null && product.stockQuantity < difference) { - throw const InsufficientStockException(); - } - } + Future changeItemQuantity(TabItem item, int delta) async { + if (delta == 0) return; try { - await barTabService.updateTabItemQuantity( + final actualDelta = await barTabService.adjustTabItemQuantity( tabItemId: item.id, - quantity: quantity, - stockAdjustment: () async { - if (difference > 0) { - await inventory.decreaseStock(item.productId, difference); - } else if (difference < 0) { - await inventory.increaseStock(item.productId, -difference); - } - }, + delta: delta, ); + inventory.applyStockDelta(item.productId, -actualDelta); await _reloadTabs(); } catch (e, stack) { @@ -160,7 +133,9 @@ class BarScreenViewModel extends ChangeNotifier { } } - Future closeSelectedTab({PaymentMethod paymentMethod = PaymentMethod.cash}) async { + Future closeSelectedTab({ + PaymentMethod paymentMethod = PaymentMethod.cash, + }) async { final tab = selectedTab; if (tab == null) return; @@ -177,7 +152,10 @@ class BarScreenViewModel extends ChangeNotifier { } } - Future closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}) async { + Future closeTab( + String tabId, { + PaymentMethod paymentMethod = PaymentMethod.cash, + }) async { try { await barTabService.closeTab(tabId, paymentMethod: paymentMethod); await _reloadTabs(); diff --git a/lib/viewmodels/inventory_view_model.dart b/lib/viewmodels/inventory_view_model.dart index 655d138..a9194b0 100644 --- a/lib/viewmodels/inventory_view_model.dart +++ b/lib/viewmodels/inventory_view_model.dart @@ -65,4 +65,16 @@ class InventoryViewModel extends ChangeNotifier { rethrow; } } + + void applyStockDelta(String productId, int delta) { + if (delta == 0) return; + + final index = _products.indexWhere((product) => product.id == productId); + if (index == -1) return; + + _products[index] = _products[index].copyWith( + stockQuantity: _products[index].stockQuantity + delta, + ); + notifyListeners(); + } } diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 9ec86a8..90fc604 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -343,7 +343,7 @@ class _TabPanel extends StatefulWidget { final Map stockByProductId; final VoidCallback onNewTabPressed; final ValueChanged onTabSelected; - final Future Function(TabItem item, int quantity) onItemQuantityChanged; + final Future Function(TabItem item, int delta) onItemQuantityChanged; final VoidCallback onCloseTabPressed; final Future Function(String) onTabClosed; @@ -634,7 +634,7 @@ class _OpenTabsList extends StatelessWidget { class _SelectedTabDetails extends StatelessWidget { final BarTab tab; final Map stockByProductId; - final Future Function(TabItem item, int quantity) onItemQuantityChanged; + final Future Function(TabItem item, int delta) onItemQuantityChanged; final VoidCallback onCloseTabPressed; const _SelectedTabDetails({ @@ -747,7 +747,7 @@ class _SelectedTabDetails extends StatelessWidget { class _TabItemRow extends StatelessWidget { final TabItem item; final Map stockByProductId; - final Future Function(TabItem item, int quantity) onQuantityChanged; + final Future Function(TabItem item, int delta) onQuantityChanged; const _TabItemRow({ required this.item, @@ -796,7 +796,7 @@ class _TabItemRow extends StatelessWidget { visualDensity: VisualDensity.compact, onPressed: () async { try { - await onQuantityChanged(item, item.quantity - 1); + await onQuantityChanged(item, -1); } catch (e, stack) { Sentry.captureException(e, stackTrace: stack); if (context.mounted) { @@ -821,7 +821,7 @@ class _TabItemRow extends StatelessWidget { onPressed: canIncrease ? () async { try { - await onQuantityChanged(item, item.quantity + 1); + await onQuantityChanged(item, 1); } catch (e, stack) { if (e is! InsufficientStockException) { Sentry.captureException(e, stackTrace: stack); diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index a145309..396f707 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -44,7 +44,7 @@ class _SettingsScreenViewState extends State { void _startVersionHold() { _versionHoldTimer?.cancel(); - _versionHoldTimer = Timer(const Duration(seconds: 3), () { + _versionHoldTimer = Timer(const Duration(seconds: 1), () { _versionHoldTimer = null; if (mounted) context.push('/dev'); }); diff --git a/make_release.bat b/make_release.bat new file mode 100644 index 0000000..7e4fd03 --- /dev/null +++ b/make_release.bat @@ -0,0 +1,52 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +cd /d "%~dp0" + +echo. +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$content = [IO.File]::ReadAllText('pubspec.yaml'); " ^ + "$match = [regex]::Match($content, '(?m)(?:\A|\r?\n)version:[ \t]*([^\r\n]*)'); " ^ + "if (-not $match.Success) { Write-Error 'Could not find the version line in pubspec.yaml.'; exit 1 }; " ^ + "Write-Output ('Current version: ' + $match.Groups[1].Value.Trim())" + +if errorlevel 1 ( + echo Could not read the current version. Release cancelled. + exit /b 1 +) + +set "APP_VERSION=" +set /p "APP_VERSION=Enter release version (for example 1.0.10 or 1.0.10+2): " + +if not defined APP_VERSION ( + echo No version entered. Release cancelled. + exit /b 1 +) + +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$version = $env:APP_VERSION; " ^ + "if ($version -notmatch '\A[0-9]+\.[0-9]+\.[0-9]+(\+[0-9]+)?\z') { " ^ + " Write-Error 'Use a version like 1.0.10 or 1.0.10+2.'; exit 1 " ^ + "}; " ^ + "$path = 'pubspec.yaml'; " ^ + "$content = [IO.File]::ReadAllText($path); " ^ + "$updated = ([regex]::new('(?m)(\A|\r?\n)version:[^\r\n]*')).Replace($content, ('${1}version: ' + $version), 1); " ^ + "if ($updated -eq $content) { Write-Error 'Could not find the version line in pubspec.yaml.'; exit 1 }; " ^ + "[IO.File]::WriteAllText($path, $updated)" + +if errorlevel 1 ( + echo Version update failed. Release cancelled. + exit /b 1 +) + +echo Updated pubspec.yaml to version %APP_VERSION%. +echo Building release APK... +flutter build apk --release + +if errorlevel 1 ( + echo Release APK build failed. + exit /b 1 +) + +echo Release APK created at build\app\outputs\flutter-apk\app-release.apk +endlocal diff --git a/pubspec.yaml b/pubspec.yaml index 3807522..8203d03 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.9+1 +version: 1.0.11 environment: sdk: ^3.12.2 diff --git a/test/app_lifecycle_lock_test.dart b/test/app_lifecycle_lock_test.dart new file mode 100644 index 0000000..526878a --- /dev/null +++ b/test/app_lifecycle_lock_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltab2/app/app_lifecycle_lock.dart'; + +void main() { + test('locks when the app is no longer active', () { + var lockCount = 0; + final observer = AppLifecycleLockObserver(onLock: () => lockCount++); + + for (final state in [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.detached, + ]) { + observer.didChangeAppLifecycleState(state); + } + + expect(lockCount, 4); + }); + + test('does not lock while the app is active', () { + var lockCount = 0; + final observer = AppLifecycleLockObserver(onLock: () => lockCount++); + + observer.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect(lockCount, 0); + }); +} diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index 75817ea..84f6428 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -1,5 +1,4 @@ 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'; @@ -27,38 +26,28 @@ void main() { ); }); - test( - 'does not increase a tab item when its product is out of stock', - () async { - const product = Product( - id: 'prod-1', - name: 'Chips', - category: 'Snacks', - stockQuantity: 0, - lowStockThreshold: 5, - priceInCents: 150, - ); - const item = TabItem( - id: 'item-1', - tabId: 'tab-1', - productId: 'prod-1', - productName: 'Chips', - quantity: 20, - unitPriceInCents: 150, - ); + test('propagates an insufficient-stock error from the transaction', () async { + const item = TabItem( + id: 'item-1', + tabId: 'tab-1', + productId: 'prod-1', + productName: 'Chips', + quantity: 20, + unitPriceInCents: 150, + ); - when( - () => productService.getProducts(), - ).thenAnswer((_) async => [product]); - await inventory.load(); + when( + () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1), + ).thenAnswer((_) async => throw const InsufficientStockException()); - await expectLater( - viewModel.changeItemQuantity(item, 21), - throwsA(isA()), - ); + await expectLater( + viewModel.changeItemQuantity(item, 1), + throwsA(isA()), + ); - expect(viewModel.errorMessage, isNull); - verifyNoMoreInteractions(barTabService); - }, - ); + expect(viewModel.errorMessage, isNull); + verify( + () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1), + ).called(1); + }); } diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart index 1f544fe..12ee046 100644 --- a/test/bar_tab_service_test.dart +++ b/test/bar_tab_service_test.dart @@ -4,14 +4,28 @@ 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'; void main() { late AppDatabase database; late DriftBarTabService service; - setUp(() { + setUp(() async { database = AppDatabase(NativeDatabase.memory()); service = DriftBarTabService(database: database); + + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'prod-1', + name: 'Test Beer', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 400, + ), + ); }); tearDown(() async { @@ -59,14 +73,16 @@ void main() { 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(), - ), - ); + 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(); @@ -76,22 +92,26 @@ void main() { test('tabs sorted by openedAt descending', () async { final now = DateTime.now(); - await database.into(database.barTabs).insert( - BarTabsCompanion.insert( - id: 'tab-first', - customerName: 'First', - status: const Value('open'), - openedAt: now.subtract(const Duration(hours: 1)), - ), - ); - await database.into(database.barTabs).insert( - BarTabsCompanion.insert( - id: 'tab-second', - customerName: 'Second', - status: const Value('open'), - openedAt: now, - ), - ); + await database + .into(database.barTabs) + .insert( + BarTabsCompanion.insert( + id: 'tab-first', + customerName: 'First', + status: const Value('open'), + openedAt: now.subtract(const Duration(hours: 1)), + ), + ); + await database + .into(database.barTabs) + .insert( + BarTabsCompanion.insert( + id: 'tab-second', + customerName: 'Second', + status: const Value('open'), + openedAt: now, + ), + ); final tabs = await service.getOpenTabs(); @@ -152,9 +172,38 @@ void main() { final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.first.lineTotalInCents, 900); }); + + test('decreases stock as part of adding the item', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + + await service.addProductToTab(tabId: tab.id, product: product); + + final storedProduct = await (database.select( + database.products, + )..where((row) => row.id.equals(product.id))).getSingle(); + expect(storedProduct.stockQuantity, 99); + }); + + test('rolls back the tab insert when stock is unavailable', () 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()), + ); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items, isEmpty); + }); }); - group('updateTabItemQuantity', () { + group('adjustTabItemQuantity', () { test('updates item quantity', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); @@ -162,40 +211,67 @@ void main() { final tabItemId = (await service.getTabById(tab.id))!.items.first.id; - await service.updateTabItemQuantity( - tabItemId: tabItemId, - quantity: 5, - ); + await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 4); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items.first.quantity, 5); }); - test('deletes item when quantity is zero', () async { + test('deletes item when the delta removes all quantity', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; - await service.updateTabItemQuantity(tabItemId: tabItemId, quantity: 0); + await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items, isEmpty); }); - test('deletes item when quantity is negative', () async { + test('restores stock when quantity decreases', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); await service.addProductToTab(tabId: tab.id, product: product); final tabItemId = (await service.getTabById(tab.id))!.items.first.id; - await service.updateTabItemQuantity(tabItemId: tabItemId, quantity: -1); + await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: -1); final updatedTab = await service.getTabById(tab.id); expect(updatedTab!.items, isEmpty); + + final updatedProduct = await (database.select( + database.products, + )..where((row) => row.id.equals(product.id))).getSingle(); + expect(updatedProduct.stockQuantity, 100); }); + + test( + 'applies concurrent deltas without losing stock consistency', + () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + await service.addProductToTab(tabId: tab.id, product: product); + + final tabItemId = (await service.getTabById(tab.id))!.items.first.id; + + final deltas = await Future.wait([ + service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 1), + service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 1), + ]); + + expect(deltas, [1, 1]); + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items.first.quantity, 3); + + final updatedProduct = await (database.select( + database.products, + )..where((row) => row.id.equals(product.id))).getSingle(); + expect(updatedProduct.stockQuantity, 97); + }, + ); }); group('closeTab', () { @@ -257,22 +333,26 @@ void main() { test('returns closed tabs sorted by closedAt descending', () async { final now = DateTime.now(); - await database.into(database.closedTabs).insert( - ClosedTabsCompanion.insert( - id: 'closed-first', - originalTabId: 'original-1', - customerName: 'First', - closedAt: now.subtract(const Duration(hours: 1)), - ), - ); - await database.into(database.closedTabs).insert( - ClosedTabsCompanion.insert( - id: 'closed-second', - originalTabId: 'original-2', - customerName: 'Second', - closedAt: now, - ), - ); + await database + .into(database.closedTabs) + .insert( + ClosedTabsCompanion.insert( + id: 'closed-first', + originalTabId: 'original-1', + customerName: 'First', + closedAt: now.subtract(const Duration(hours: 1)), + ), + ); + await database + .into(database.closedTabs) + .insert( + ClosedTabsCompanion.insert( + id: 'closed-second', + originalTabId: 'original-2', + customerName: 'Second', + closedAt: now, + ), + ); final closedTabs = await service.getClosedTabs(); diff --git a/test/product_service_test.dart b/test/product_service_test.dart index c872b48..392b729 100644 --- a/test/product_service_test.dart +++ b/test/product_service_test.dart @@ -26,27 +26,31 @@ void main() { }); test('returns only active products', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: '1', - name: 'Active Product', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 2, - priceInCents: 500, - ), - ); - await database.into(database.products).insert( - ProductsCompanion.insert( - id: '2', - name: 'Inactive Product', - category: 'Drinks', - stockQuantity: 5, - lowStockThreshold: 1, - priceInCents: 300, - active: const Value(false), - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: '1', + name: 'Active Product', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: '2', + name: 'Inactive Product', + category: 'Drinks', + stockQuantity: 5, + lowStockThreshold: 1, + priceInCents: 300, + active: const Value(false), + ), + ); final products = await service.getProducts(); @@ -55,26 +59,30 @@ void main() { }); test('returns products sorted by name', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: '1', - name: 'Zebra', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 2, - priceInCents: 500, - ), - ); - await database.into(database.products).insert( - ProductsCompanion.insert( - id: '2', - name: 'Apple', - category: 'Drinks', - stockQuantity: 5, - lowStockThreshold: 1, - priceInCents: 300, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: '1', + name: 'Zebra', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: '2', + name: 'Apple', + category: 'Drinks', + stockQuantity: 5, + lowStockThreshold: 1, + priceInCents: 300, + ), + ); final products = await service.getProducts(); @@ -85,16 +93,18 @@ void main() { group('getProductById', () { test('returns product when exists', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: 'prod-123', - name: 'Test Product', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 2, - priceInCents: 500, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'prod-123', + name: 'Test Product', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); final product = await service.getProductById('prod-123'); @@ -128,16 +138,18 @@ void main() { group('updateProduct', () { test('updates product fields', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: 'update-test', - name: 'Original', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 2, - priceInCents: 500, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'update-test', + name: 'Original', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); await service.updateProduct( const Product( @@ -160,16 +172,18 @@ void main() { group('deleteProduct', () { test('removes product from database', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: 'delete-test', - name: 'To Delete', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 2, - priceInCents: 500, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'delete-test', + name: 'To Delete', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); await service.deleteProduct('delete-test'); @@ -180,16 +194,18 @@ void main() { group('decreaseStock', () { test('decreases stock quantity', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: 'stock-test', - name: 'Stock Test', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 2, - priceInCents: 500, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'stock-test', + name: 'Stock Test', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); await service.decreaseStock('stock-test', 3); @@ -205,16 +221,18 @@ void main() { }); test('throws when not enough stock', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: 'low-stock', - name: 'Low Stock', - category: 'Drinks', - stockQuantity: 3, - lowStockThreshold: 1, - priceInCents: 500, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'low-stock', + name: 'Low Stock', + category: 'Drinks', + stockQuantity: 3, + lowStockThreshold: 1, + priceInCents: 500, + ), + ); expect( () => service.decreaseStock('low-stock', 5), @@ -225,16 +243,18 @@ void main() { group('increaseStock', () { test('increases stock quantity', () async { - await database.into(database.products).insert( - ProductsCompanion.insert( - id: 'increase-test', - name: 'Increase Test', - category: 'Drinks', - stockQuantity: 10, - lowStockThreshold: 2, - priceInCents: 500, - ), - ); + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'increase-test', + name: 'Increase Test', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); await service.increaseStock('increase-test', 5); @@ -248,6 +268,56 @@ void main() { throwsA(isA()), ); }); + + test('rejects non-positive stock adjustments', () async { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'invalid-adjustment', + name: 'Invalid Adjustment', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 2, + priceInCents: 500, + ), + ); + + expect( + () => service.decreaseStock('invalid-adjustment', 0), + throwsA(isA()), + ); + expect( + () => service.increaseStock('invalid-adjustment', -1), + throwsA(isA()), + ); + }); + + test('does not allow concurrent decrements below zero', () async { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: 'concurrent-stock', + name: 'Concurrent Stock', + category: 'Drinks', + stockQuantity: 1, + lowStockThreshold: 0, + priceInCents: 500, + ), + ); + + await expectLater( + Future.wait([ + service.decreaseStock('concurrent-stock', 1), + service.decreaseStock('concurrent-stock', 1), + ]), + throwsA(isA()), + ); + + final product = await service.getProductById('concurrent-stock'); + expect(product!.stockQuantity, 0); + }); }); }); }