fix: Make transactions atomic / AutoLogout

This commit is contained in:
2026-08-03 02:22:26 +02:00
parent 6060ca2713
commit 0997d110d4
14 changed files with 604 additions and 285 deletions
+15
View File
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'app_lifecycle_lock.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../viewmodels/pin_lock_view_model.dart';
import '../viewmodels/settings_view_model.dart'; import '../viewmodels/settings_view_model.dart';
import '../theme.dart'; import '../theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -17,15 +19,28 @@ class KoolTabApp extends StatefulWidget {
} }
class _KoolTabAppState extends State<KoolTabApp> { class _KoolTabAppState extends State<KoolTabApp> {
late final AppLifecycleLockObserver _lifecycleLockObserver;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_lifecycleLockObserver = AppLifecycleLockObserver(
onLock: () => context.read<PinLockViewModel>().lock(),
);
WidgetsBinding.instance.addObserver(_lifecycleLockObserver);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SettingsViewModel>().ensureLoaded(); context.read<SettingsViewModel>().ensureLoaded();
}); });
} }
@override
void dispose() {
WidgetsBinding.instance.removeObserver(_lifecycleLockObserver);
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.watch<SettingsViewModel>().settings; final settings = context.watch<SettingsViewModel>().settings;
+20
View File
@@ -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;
}
}
}
+97 -41
View File
@@ -8,6 +8,7 @@ import '../models/closed_tab_item.dart';
import '../models/payment_method.dart'; import '../models/payment_method.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/tab_item.dart'; import '../models/tab_item.dart';
import 'product_service.dart';
abstract class BarTabService { abstract class BarTabService {
Future<List<BarTab>> getOpenTabs(); Future<List<BarTab>> getOpenTabs();
@@ -19,18 +20,19 @@ abstract class BarTabService {
Future<void> addProductToTab({ Future<void> addProductToTab({
required String tabId, required String tabId,
required Product product, required Product product,
Future<void> Function()? stockAdjustment,
}); });
Future<void> updateTabItemQuantity({ Future<int> adjustTabItemQuantity({
required String tabItemId, required String tabItemId,
required int quantity, required int delta,
Future<void> Function()? stockAdjustment,
}); });
/// Archives the tab's current items into history and clears them. /// Archives the tab's current items into history and clears them.
/// The tab itself stays open under the same customer name. /// The tab itself stays open under the same customer name.
Future<void> closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}); Future<void> closeTab(
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
});
Future<List<ClosedTab>> getClosedTabs(); Future<List<ClosedTab>> getClosedTabs();
@@ -94,6 +96,54 @@ class DriftBarTabService implements BarTabService {
return rows.map(_mapItemRow).toList(); return rows.map(_mapItemRow).toList();
} }
Future<void> _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<void> _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 @override
Future<List<BarTab>> getOpenTabs() async { Future<List<BarTab>> getOpenTabs() async {
final query = database.select(database.barTabs) final query = database.select(database.barTabs)
@@ -157,9 +207,10 @@ class DriftBarTabService implements BarTabService {
Future<void> addProductToTab({ Future<void> addProductToTab({
required String tabId, required String tabId,
required Product product, required Product product,
Future<void> Function()? stockAdjustment,
}) async { }) async {
await database.transaction(() async { await database.transaction(() async {
await _decreaseProductStock(product.id, 1);
final existingItemQuery = database.select(database.tabItems) final existingItemQuery = database.select(database.tabItems)
..where( ..where(
(item) => (item) =>
@@ -176,10 +227,6 @@ class DriftBarTabService implements BarTabService {
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
); );
if (stockAdjustment != null) {
await stockAdjustment();
}
return; return;
} }
@@ -196,46 +243,60 @@ class DriftBarTabService implements BarTabService {
createdAt: DateTime.now(), createdAt: DateTime.now(),
), ),
); );
if (stockAdjustment != null) {
await stockAdjustment();
}
}); });
} }
@override @override
Future<void> updateTabItemQuantity({ Future<int> adjustTabItemQuantity({
required String tabItemId, required String tabItemId,
required int quantity, required int delta,
Future<void> Function()? stockAdjustment,
}) async { }) async {
await database.transaction(() async { return database.transaction(() async {
if (quantity <= 0) { final item = await (database.select(
final deleteQuery = database.delete(database.tabItems) database.tabItems,
..where((item) => item.id.equals(tabItemId)); )..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
await deleteQuery.go(); if (item == null || delta == 0) return 0;
if (stockAdjustment != null) { final requestedQuantity = item.quantity + delta;
await stockAdjustment(); 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) return actualDelta;
..where((item) => item.id.equals(tabItemId));
await updateQuery.write(TabItemsCompanion(quantity: Value(quantity)));
if (stockAdjustment != null) {
await stockAdjustment();
}
}); });
} }
@override @override
Future<void> closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}) async { Future<void> closeTab(
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
}) async {
await database.transaction(() async { await database.transaction(() async {
final tabQuery = database.select(database.barTabs) final tabQuery = database.select(database.barTabs)
..where((tab) => tab.id.equals(tabId)); ..where((tab) => tab.id.equals(tabId));
@@ -307,8 +368,7 @@ class DriftBarTabService implements BarTabService {
..limit(limit, offset: offset); ..limit(limit, offset: offset);
if (customerName != null && customerName.isNotEmpty) { if (customerName != null && customerName.isNotEmpty) {
query = query query = query..where((tab) => tab.customerName.equals(customerName));
..where((tab) => tab.customerName.equals(customerName));
} }
final closedTabRows = await query.get(); final closedTabRows = await query.get();
@@ -355,10 +415,6 @@ class DriftBarTabService implements BarTabService {
@override @override
Future<List<String>> getDistinctCustomerNames() async { Future<List<String>> getDistinctCustomerNames() async {
final rows = await database.select(database.closedTabs).get(); final rows = await database.select(database.closedTabs).get();
return rows return rows.map((row) => row.customerName).toSet().toList()..sort();
.map((row) => row.customerName)
.toSet()
.toList()
..sort();
} }
} }
+35 -18
View File
@@ -130,31 +130,48 @@ class DriftProductService implements ProductService {
@override @override
Future<void> decreaseStock(String productId, int amount) async { Future<void> decreaseStock(String productId, int amount) async {
final product = await getProductById(productId); _validateStockAmount(amount);
if (product == null) { final updatedRows = await database.customUpdate(
throw Exception('Product not found'); 'UPDATE products '
} 'SET stock_quantity = stock_quantity - ? '
'WHERE id = ? AND stock_quantity >= ?',
if (product.stockQuantity < amount) { variables: [
throw const InsufficientStockException(); Variable.withInt(amount),
} Variable.withString(productId),
Variable.withInt(amount),
await updateProduct( ],
product.copyWith(stockQuantity: product.stockQuantity - 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 @override
Future<void> increaseStock(String productId, int amount) async { Future<void> increaseStock(String productId, int amount) async {
final product = await getProductById(productId); _validateStockAmount(amount);
if (product == null) { final updatedRows = await database.customUpdate(
throw Exception('Product not found'); 'UPDATE products '
} 'SET stock_quantity = stock_quantity + ? '
'WHERE id = ?',
await updateProduct( variables: [Variable.withInt(amount), Variable.withString(productId)],
product.copyWith(stockQuantity: product.stockQuantity + amount), 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');
}
} }
} }
+14 -36
View File
@@ -95,16 +95,9 @@ class BarScreenViewModel extends ChangeNotifier {
if (tab == null) return; if (tab == null) return;
if (product.stockQuantity <= 0) {
throw const InsufficientStockException();
}
try { try {
await barTabService.addProductToTab( await barTabService.addProductToTab(tabId: tab.id, product: product);
tabId: tab.id, inventory.applyStockDelta(product.id, -1);
product: product,
stockAdjustment: () => inventory.decreaseStock(product.id, 1),
);
await _reloadTabs(); await _reloadTabs();
} catch (e, stack) { } catch (e, stack) {
@@ -118,35 +111,15 @@ class BarScreenViewModel extends ChangeNotifier {
} }
} }
Future<void> changeItemQuantity(TabItem item, int quantity) async { Future<void> changeItemQuantity(TabItem item, int delta) async {
final difference = quantity - item.quantity; if (delta == 0) return;
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();
}
}
try { try {
await barTabService.updateTabItemQuantity( final actualDelta = await barTabService.adjustTabItemQuantity(
tabItemId: item.id, tabItemId: item.id,
quantity: quantity, delta: delta,
stockAdjustment: () async {
if (difference > 0) {
await inventory.decreaseStock(item.productId, difference);
} else if (difference < 0) {
await inventory.increaseStock(item.productId, -difference);
}
},
); );
inventory.applyStockDelta(item.productId, -actualDelta);
await _reloadTabs(); await _reloadTabs();
} catch (e, stack) { } catch (e, stack) {
@@ -160,7 +133,9 @@ class BarScreenViewModel extends ChangeNotifier {
} }
} }
Future<void> closeSelectedTab({PaymentMethod paymentMethod = PaymentMethod.cash}) async { Future<void> closeSelectedTab({
PaymentMethod paymentMethod = PaymentMethod.cash,
}) async {
final tab = selectedTab; final tab = selectedTab;
if (tab == null) return; if (tab == null) return;
@@ -177,7 +152,10 @@ class BarScreenViewModel extends ChangeNotifier {
} }
} }
Future<void> closeTab(String tabId, {PaymentMethod paymentMethod = PaymentMethod.cash}) async { Future<void> closeTab(
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
}) async {
try { try {
await barTabService.closeTab(tabId, paymentMethod: paymentMethod); await barTabService.closeTab(tabId, paymentMethod: paymentMethod);
await _reloadTabs(); await _reloadTabs();
+12
View File
@@ -65,4 +65,16 @@ class InventoryViewModel extends ChangeNotifier {
rethrow; 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();
}
} }
+5 -5
View File
@@ -343,7 +343,7 @@ class _TabPanel extends StatefulWidget {
final Map<String, int> stockByProductId; final Map<String, int> stockByProductId;
final VoidCallback onNewTabPressed; final VoidCallback onNewTabPressed;
final ValueChanged<String> onTabSelected; final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int quantity) onItemQuantityChanged; final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed; final VoidCallback onCloseTabPressed;
final Future<void> Function(String) onTabClosed; final Future<void> Function(String) onTabClosed;
@@ -634,7 +634,7 @@ class _OpenTabsList extends StatelessWidget {
class _SelectedTabDetails extends StatelessWidget { class _SelectedTabDetails extends StatelessWidget {
final BarTab tab; final BarTab tab;
final Map<String, int> stockByProductId; final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int quantity) onItemQuantityChanged; final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed; final VoidCallback onCloseTabPressed;
const _SelectedTabDetails({ const _SelectedTabDetails({
@@ -747,7 +747,7 @@ class _SelectedTabDetails extends StatelessWidget {
class _TabItemRow extends StatelessWidget { class _TabItemRow extends StatelessWidget {
final TabItem item; final TabItem item;
final Map<String, int> stockByProductId; final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int quantity) onQuantityChanged; final Future<void> Function(TabItem item, int delta) onQuantityChanged;
const _TabItemRow({ const _TabItemRow({
required this.item, required this.item,
@@ -796,7 +796,7 @@ class _TabItemRow extends StatelessWidget {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: () async { onPressed: () async {
try { try {
await onQuantityChanged(item, item.quantity - 1); await onQuantityChanged(item, -1);
} catch (e, stack) { } catch (e, stack) {
Sentry.captureException(e, stackTrace: stack); Sentry.captureException(e, stackTrace: stack);
if (context.mounted) { if (context.mounted) {
@@ -821,7 +821,7 @@ class _TabItemRow extends StatelessWidget {
onPressed: canIncrease onPressed: canIncrease
? () async { ? () async {
try { try {
await onQuantityChanged(item, item.quantity + 1); await onQuantityChanged(item, 1);
} catch (e, stack) { } catch (e, stack) {
if (e is! InsufficientStockException) { if (e is! InsufficientStockException) {
Sentry.captureException(e, stackTrace: stack); Sentry.captureException(e, stackTrace: stack);
+1 -1
View File
@@ -44,7 +44,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
void _startVersionHold() { void _startVersionHold() {
_versionHoldTimer?.cancel(); _versionHoldTimer?.cancel();
_versionHoldTimer = Timer(const Duration(seconds: 3), () { _versionHoldTimer = Timer(const Duration(seconds: 1), () {
_versionHoldTimer = null; _versionHoldTimer = null;
if (mounted) context.push('/dev'); if (mounted) context.push('/dev');
}); });
+52
View File
@@ -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
+1 -1
View File
@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.12.2 sdk: ^3.12.2
+30
View File
@@ -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);
});
}
+21 -32
View File
@@ -1,5 +1,4 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/models/tab_item.dart'; import 'package:kooltab2/models/tab_item.dart';
import 'package:kooltab2/services/bar_tab_service.dart'; import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/product_service.dart'; import 'package:kooltab2/services/product_service.dart';
@@ -27,38 +26,28 @@ void main() {
); );
}); });
test( test('propagates an insufficient-stock error from the transaction', () async {
'does not increase a tab item when its product is out of stock', const item = TabItem(
() async { id: 'item-1',
const product = Product( tabId: 'tab-1',
id: 'prod-1', productId: 'prod-1',
name: 'Chips', productName: 'Chips',
category: 'Snacks', quantity: 20,
stockQuantity: 0, unitPriceInCents: 150,
lowStockThreshold: 5, );
priceInCents: 150,
);
const item = TabItem(
id: 'item-1',
tabId: 'tab-1',
productId: 'prod-1',
productName: 'Chips',
quantity: 20,
unitPriceInCents: 150,
);
when( when(
() => productService.getProducts(), () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
).thenAnswer((_) async => [product]); ).thenAnswer((_) async => throw const InsufficientStockException());
await inventory.load();
await expectLater( await expectLater(
viewModel.changeItemQuantity(item, 21), viewModel.changeItemQuantity(item, 1),
throwsA(isA<InsufficientStockException>()), throwsA(isA<InsufficientStockException>()),
); );
expect(viewModel.errorMessage, isNull); expect(viewModel.errorMessage, isNull);
verifyNoMoreInteractions(barTabService); verify(
}, () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
); ).called(1);
});
} }
+130 -50
View File
@@ -4,14 +4,28 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:kooltab2/database/app_database.dart'; import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/models/product.dart'; import 'package:kooltab2/models/product.dart';
import 'package:kooltab2/services/bar_tab_service.dart'; import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/services/product_service.dart';
void main() { void main() {
late AppDatabase database; late AppDatabase database;
late DriftBarTabService service; late DriftBarTabService service;
setUp(() { setUp(() async {
database = AppDatabase(NativeDatabase.memory()); database = AppDatabase(NativeDatabase.memory());
service = DriftBarTabService(database: database); 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 { tearDown(() async {
@@ -59,14 +73,16 @@ void main() {
test('returns only open tabs', () async { test('returns only open tabs', () async {
await service.createTab(customerName: 'Open Tab'); await service.createTab(customerName: 'Open Tab');
await database.into(database.barTabs).insert( await database
BarTabsCompanion.insert( .into(database.barTabs)
id: 'closed-tab', .insert(
customerName: 'Closed Tab', BarTabsCompanion.insert(
status: const Value('closed'), id: 'closed-tab',
openedAt: DateTime.now(), customerName: 'Closed Tab',
), status: const Value('closed'),
); openedAt: DateTime.now(),
),
);
final tabs = await service.getOpenTabs(); final tabs = await service.getOpenTabs();
@@ -76,22 +92,26 @@ void main() {
test('tabs sorted by openedAt descending', () async { test('tabs sorted by openedAt descending', () async {
final now = DateTime.now(); final now = DateTime.now();
await database.into(database.barTabs).insert( await database
BarTabsCompanion.insert( .into(database.barTabs)
id: 'tab-first', .insert(
customerName: 'First', BarTabsCompanion.insert(
status: const Value('open'), id: 'tab-first',
openedAt: now.subtract(const Duration(hours: 1)), customerName: 'First',
), status: const Value('open'),
); openedAt: now.subtract(const Duration(hours: 1)),
await database.into(database.barTabs).insert( ),
BarTabsCompanion.insert( );
id: 'tab-second', await database
customerName: 'Second', .into(database.barTabs)
status: const Value('open'), .insert(
openedAt: now, BarTabsCompanion.insert(
), id: 'tab-second',
); customerName: 'Second',
status: const Value('open'),
openedAt: now,
),
);
final tabs = await service.getOpenTabs(); final tabs = await service.getOpenTabs();
@@ -152,9 +172,38 @@ void main() {
final updatedTab = await service.getTabById(tab.id); final updatedTab = await service.getTabById(tab.id);
expect(updatedTab!.items.first.lineTotalInCents, 900); 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<InsufficientStockException>()),
);
final updatedTab = await service.getTabById(tab.id);
expect(updatedTab!.items, isEmpty);
});
}); });
group('updateTabItemQuantity', () { group('adjustTabItemQuantity', () {
test('updates item quantity', () async { test('updates item quantity', () async {
final tab = await service.createTab(customerName: 'John'); final tab = await service.createTab(customerName: 'John');
final product = createTestProduct(); final product = createTestProduct();
@@ -162,40 +211,67 @@ void main() {
final tabItemId = (await service.getTabById(tab.id))!.items.first.id; final tabItemId = (await service.getTabById(tab.id))!.items.first.id;
await service.updateTabItemQuantity( await service.adjustTabItemQuantity(tabItemId: tabItemId, delta: 4);
tabItemId: tabItemId,
quantity: 5,
);
final updatedTab = await service.getTabById(tab.id); final updatedTab = await service.getTabById(tab.id);
expect(updatedTab!.items.first.quantity, 5); 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 tab = await service.createTab(customerName: 'John');
final product = createTestProduct(); final product = createTestProduct();
await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product);
final tabItemId = (await service.getTabById(tab.id))!.items.first.id; 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); final updatedTab = await service.getTabById(tab.id);
expect(updatedTab!.items, isEmpty); 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 tab = await service.createTab(customerName: 'John');
final product = createTestProduct(); final product = createTestProduct();
await service.addProductToTab(tabId: tab.id, product: product); await service.addProductToTab(tabId: tab.id, product: product);
final tabItemId = (await service.getTabById(tab.id))!.items.first.id; 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); final updatedTab = await service.getTabById(tab.id);
expect(updatedTab!.items, isEmpty); 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', () { group('closeTab', () {
@@ -257,22 +333,26 @@ void main() {
test('returns closed tabs sorted by closedAt descending', () async { test('returns closed tabs sorted by closedAt descending', () async {
final now = DateTime.now(); final now = DateTime.now();
await database.into(database.closedTabs).insert( await database
ClosedTabsCompanion.insert( .into(database.closedTabs)
id: 'closed-first', .insert(
originalTabId: 'original-1', ClosedTabsCompanion.insert(
customerName: 'First', id: 'closed-first',
closedAt: now.subtract(const Duration(hours: 1)), originalTabId: 'original-1',
), customerName: 'First',
); closedAt: now.subtract(const Duration(hours: 1)),
await database.into(database.closedTabs).insert( ),
ClosedTabsCompanion.insert( );
id: 'closed-second', await database
originalTabId: 'original-2', .into(database.closedTabs)
customerName: 'Second', .insert(
closedAt: now, ClosedTabsCompanion.insert(
), id: 'closed-second',
); originalTabId: 'original-2',
customerName: 'Second',
closedAt: now,
),
);
final closedTabs = await service.getClosedTabs(); final closedTabs = await service.getClosedTabs();
+171 -101
View File
@@ -26,27 +26,31 @@ void main() {
}); });
test('returns only active products', () async { test('returns only active products', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: '1', .insert(
name: 'Active Product', ProductsCompanion.insert(
category: 'Drinks', id: '1',
stockQuantity: 10, name: 'Active Product',
lowStockThreshold: 2, category: 'Drinks',
priceInCents: 500, stockQuantity: 10,
), lowStockThreshold: 2,
); priceInCents: 500,
await database.into(database.products).insert( ),
ProductsCompanion.insert( );
id: '2', await database
name: 'Inactive Product', .into(database.products)
category: 'Drinks', .insert(
stockQuantity: 5, ProductsCompanion.insert(
lowStockThreshold: 1, id: '2',
priceInCents: 300, name: 'Inactive Product',
active: const Value(false), category: 'Drinks',
), stockQuantity: 5,
); lowStockThreshold: 1,
priceInCents: 300,
active: const Value(false),
),
);
final products = await service.getProducts(); final products = await service.getProducts();
@@ -55,26 +59,30 @@ void main() {
}); });
test('returns products sorted by name', () async { test('returns products sorted by name', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: '1', .insert(
name: 'Zebra', ProductsCompanion.insert(
category: 'Drinks', id: '1',
stockQuantity: 10, name: 'Zebra',
lowStockThreshold: 2, category: 'Drinks',
priceInCents: 500, stockQuantity: 10,
), lowStockThreshold: 2,
); priceInCents: 500,
await database.into(database.products).insert( ),
ProductsCompanion.insert( );
id: '2', await database
name: 'Apple', .into(database.products)
category: 'Drinks', .insert(
stockQuantity: 5, ProductsCompanion.insert(
lowStockThreshold: 1, id: '2',
priceInCents: 300, name: 'Apple',
), category: 'Drinks',
); stockQuantity: 5,
lowStockThreshold: 1,
priceInCents: 300,
),
);
final products = await service.getProducts(); final products = await service.getProducts();
@@ -85,16 +93,18 @@ void main() {
group('getProductById', () { group('getProductById', () {
test('returns product when exists', () async { test('returns product when exists', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: 'prod-123', .insert(
name: 'Test Product', ProductsCompanion.insert(
category: 'Drinks', id: 'prod-123',
stockQuantity: 10, name: 'Test Product',
lowStockThreshold: 2, category: 'Drinks',
priceInCents: 500, stockQuantity: 10,
), lowStockThreshold: 2,
); priceInCents: 500,
),
);
final product = await service.getProductById('prod-123'); final product = await service.getProductById('prod-123');
@@ -128,16 +138,18 @@ void main() {
group('updateProduct', () { group('updateProduct', () {
test('updates product fields', () async { test('updates product fields', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: 'update-test', .insert(
name: 'Original', ProductsCompanion.insert(
category: 'Drinks', id: 'update-test',
stockQuantity: 10, name: 'Original',
lowStockThreshold: 2, category: 'Drinks',
priceInCents: 500, stockQuantity: 10,
), lowStockThreshold: 2,
); priceInCents: 500,
),
);
await service.updateProduct( await service.updateProduct(
const Product( const Product(
@@ -160,16 +172,18 @@ void main() {
group('deleteProduct', () { group('deleteProduct', () {
test('removes product from database', () async { test('removes product from database', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: 'delete-test', .insert(
name: 'To Delete', ProductsCompanion.insert(
category: 'Drinks', id: 'delete-test',
stockQuantity: 10, name: 'To Delete',
lowStockThreshold: 2, category: 'Drinks',
priceInCents: 500, stockQuantity: 10,
), lowStockThreshold: 2,
); priceInCents: 500,
),
);
await service.deleteProduct('delete-test'); await service.deleteProduct('delete-test');
@@ -180,16 +194,18 @@ void main() {
group('decreaseStock', () { group('decreaseStock', () {
test('decreases stock quantity', () async { test('decreases stock quantity', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: 'stock-test', .insert(
name: 'Stock Test', ProductsCompanion.insert(
category: 'Drinks', id: 'stock-test',
stockQuantity: 10, name: 'Stock Test',
lowStockThreshold: 2, category: 'Drinks',
priceInCents: 500, stockQuantity: 10,
), lowStockThreshold: 2,
); priceInCents: 500,
),
);
await service.decreaseStock('stock-test', 3); await service.decreaseStock('stock-test', 3);
@@ -205,16 +221,18 @@ void main() {
}); });
test('throws when not enough stock', () async { test('throws when not enough stock', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: 'low-stock', .insert(
name: 'Low Stock', ProductsCompanion.insert(
category: 'Drinks', id: 'low-stock',
stockQuantity: 3, name: 'Low Stock',
lowStockThreshold: 1, category: 'Drinks',
priceInCents: 500, stockQuantity: 3,
), lowStockThreshold: 1,
); priceInCents: 500,
),
);
expect( expect(
() => service.decreaseStock('low-stock', 5), () => service.decreaseStock('low-stock', 5),
@@ -225,16 +243,18 @@ void main() {
group('increaseStock', () { group('increaseStock', () {
test('increases stock quantity', () async { test('increases stock quantity', () async {
await database.into(database.products).insert( await database
ProductsCompanion.insert( .into(database.products)
id: 'increase-test', .insert(
name: 'Increase Test', ProductsCompanion.insert(
category: 'Drinks', id: 'increase-test',
stockQuantity: 10, name: 'Increase Test',
lowStockThreshold: 2, category: 'Drinks',
priceInCents: 500, stockQuantity: 10,
), lowStockThreshold: 2,
); priceInCents: 500,
),
);
await service.increaseStock('increase-test', 5); await service.increaseStock('increase-test', 5);
@@ -248,6 +268,56 @@ void main() {
throwsA(isA<Exception>()), throwsA(isA<Exception>()),
); );
}); });
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<ArgumentError>()),
);
expect(
() => service.increaseStock('invalid-adjustment', -1),
throwsA(isA<ArgumentError>()),
);
});
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<InsufficientStockException>()),
);
final product = await service.getProductById('concurrent-stock');
expect(product!.stockQuantity, 0);
});
}); });
}); });
} }