diff --git a/pubspec.lock b/pubspec.lock index b930472..bae0bc8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -600,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" native_toolchain_c: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6368ea0..3e5747d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dev_dependencies: flutter_lints: ^6.0.0 drift_dev: ^2.34.2+1 build_runner: ^2.15.0 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/bar_tab_model_test.dart b/test/bar_tab_model_test.dart new file mode 100644 index 0000000..b49dfdb --- /dev/null +++ b/test/bar_tab_model_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltab2/models/bar_tab.dart'; +import 'package:kooltab2/models/tab_item.dart'; + +void main() { + group('BarTab', () { + group('totalInCents', () { + test('calculates total from items', () { + 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: 400, + ), + const TabItem( + id: 'item-2', + tabId: 'tab-1', + productId: 'prod-2', + productName: 'Wine', + quantity: 1, + unitPriceInCents: 600, + ), + ], + ); + + expect(tab.totalInCents, 1400); + }); + + test('returns 0 for empty tab', () { + final tab = BarTab( + id: 'tab-1', + customerName: 'John', + status: 'open', + openedAt: DateTime.now(), + items: const [], + ); + + expect(tab.totalInCents, 0); + }); + }); + + group('itemCount', () { + test('sums quantities of all items', () { + 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: 3, + unitPriceInCents: 400, + ), + const TabItem( + id: 'item-2', + tabId: 'tab-1', + productId: 'prod-2', + productName: 'Wine', + quantity: 2, + unitPriceInCents: 600, + ), + ], + ); + + expect(tab.itemCount, 5); + }); + + test('returns 0 for empty tab', () { + final tab = BarTab( + id: 'tab-1', + customerName: 'John', + status: 'open', + openedAt: DateTime.now(), + items: const [], + ); + + 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); + }); + }); + }); +} diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart new file mode 100644 index 0000000..1f544fe --- /dev/null +++ b/test/bar_tab_service_test.dart @@ -0,0 +1,284 @@ +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'; + +void main() { + late AppDatabase database; + late DriftBarTabService service; + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + service = DriftBarTabService(database: database); + }); + + tearDown(() async { + await database.close(); + }); + + Product createTestProduct({ + String id = 'prod-1', + String name = 'Test Beer', + int priceInCents = 400, + }) { + return Product( + id: id, + name: name, + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: priceInCents, + ); + } + + group('DriftBarTabService', () { + group('createTab', () { + test('creates tab with open status', () async { + final tab = await service.createTab(customerName: 'John'); + + expect(tab.customerName, 'John'); + expect(tab.status, 'open'); + expect(tab.items, isEmpty); + }); + + test('creates tab with unique id', () async { + final tab1 = await service.createTab(customerName: 'John'); + final tab2 = await service.createTab(customerName: 'Jane'); + + expect(tab1.id, isNot(equals(tab2.id))); + }); + }); + + group('getOpenTabs', () { + test('returns empty list when no tabs', () async { + final tabs = await service.getOpenTabs(); + 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.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(); + + expect(tabs.first.customerName, 'Second'); + expect(tabs.last.customerName, 'First'); + }); + }); + + group('getTabById', () { + test('returns tab when exists', () async { + final created = await service.createTab(customerName: 'Test'); + + final tab = await service.getTabById(created.id); + + expect(tab, isNotNull); + expect(tab!.customerName, 'Test'); + }); + + test('returns null when not exists', () async { + final tab = await service.getTabById('nonexistent'); + expect(tab, isNull); + }); + }); + + group('addProductToTab', () { + test('adds new product to tab', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + + await service.addProductToTab(tabId: tab.id, product: product); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items.length, 1); + expect(updatedTab.items.first.productName, 'Test Beer'); + expect(updatedTab.items.first.quantity, 1); + }); + + test('increments quantity when product already in tab', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + + await service.addProductToTab(tabId: tab.id, product: product); + await service.addProductToTab(tabId: tab.id, product: product); + await service.addProductToTab(tabId: tab.id, product: product); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items.length, 1); + expect(updatedTab.items.first.quantity, 3); + }); + + test('adding product calculates correct line total', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(priceInCents: 450); + + await service.addProductToTab(tabId: tab.id, product: product); + await service.addProductToTab(tabId: tab.id, product: product); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items.first.lineTotalInCents, 900); + }); + }); + + group('updateTabItemQuantity', () { + test('updates item 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: 5, + ); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items.first.quantity, 5); + }); + + test('deletes item when quantity is zero', () 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); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items, isEmpty); + }); + + test('deletes item when quantity is negative', () 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); + + final updatedTab = await service.getTabById(tab.id); + expect(updatedTab!.items, isEmpty); + }); + }); + + group('closeTab', () { + test('archives tab items to closed tabs', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(); + await service.addProductToTab(tabId: tab.id, product: product); + await service.addProductToTab(tabId: tab.id, product: product); + + await service.closeTab(tab.id); + + final closedTabs = await service.getClosedTabs(); + expect(closedTabs.length, 1); + expect(closedTabs.first.customerName, 'John'); + expect(closedTabs.first.items.length, 1); + expect(closedTabs.first.items.first.quantity, 2); + }); + + test('clears tab items after closing', () 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!.items, isEmpty); + }); + + test('tab remains open but items cleared', () 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!.isOpen, true); + expect(updatedTab.items, isEmpty); + }); + + test('closed tab preserves item prices', () async { + final tab = await service.createTab(customerName: 'John'); + final product = createTestProduct(priceInCents: 550); + await service.addProductToTab(tabId: tab.id, product: product); + + await service.closeTab(tab.id); + + final closedTabs = await service.getClosedTabs(); + expect(closedTabs.first.items.first.unitPriceInCents, 550); + }); + }); + + group('getClosedTabs', () { + test('returns empty when no closed tabs', () async { + final tabs = await service.getClosedTabs(); + expect(tabs, isEmpty); + }); + + 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, + ), + ); + + final closedTabs = await service.getClosedTabs(); + + expect(closedTabs.first.customerName, 'Second'); + expect(closedTabs.last.customerName, 'First'); + }); + }); + }); +} diff --git a/test/inventory_view_model_test.dart b/test/inventory_view_model_test.dart new file mode 100644 index 0000000..ef6773a --- /dev/null +++ b/test/inventory_view_model_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.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; + + setUp(() { + mockService = MockProductService(); + viewModel = InventoryViewModel(productService: mockService); + }); + + setUpAll(() { + registerFallbackValue(const Product( + id: 'fallback', + name: 'Fallback', + category: 'Test', + stockQuantity: 0, + lowStockThreshold: 0, + priceInCents: 0, + )); + }); + + 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); + + await viewModel.load(); + + 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); + }); + }); + + group('decreaseStock', () { + test('decreases stock via service', () async { + when(() => mockService.decreaseStock('prod-1', 3)) + .thenAnswer((_) async {}); + + await viewModel.decreaseStock('prod-1', 3); + + 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); + }); + }); + + group('increaseStock', () { + test('increases stock via service', () async { + when(() => mockService.increaseStock('prod-1', 5)) + .thenAnswer((_) async {}); + + 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); + }); + }); + + 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 {}); + + await viewModel.load(); + await viewModel.decreaseStock('unknown', 1); + + verify(() => mockService.decreaseStock('unknown', 1)).called(1); + expect(viewModel.products.length, 2); + }); + }); + }); +} diff --git a/test/pin_lock_service_test.dart b/test/pin_lock_service_test.dart new file mode 100644 index 0000000..d891264 --- /dev/null +++ b/test/pin_lock_service_test.dart @@ -0,0 +1,150 @@ +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'; + +class MockFlutterSecureStorage extends Mock + implements FlutterSecureStorage {} + +String _computeHash(String pin, String salt) { + final bytes = utf8.encode('$salt:$pin'); + return sha256.convert(bytes).toString(); +} + +void main() { + late PinLockService service; + late MockFlutterSecureStorage mockStorage; + + setUp(() { + mockStorage = MockFlutterSecureStorage(); + service = PinLockService(storage: mockStorage); + }); + + 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); + }); + + test('returns false when hash is empty', () async { + when(() => mockStorage.read(key: 'pin_hash')) + .thenAnswer((_) async => ''); + + final result = await service.hasPin(); + + expect(result, false); + }); + + test('returns true when hash exists', () async { + when(() => mockStorage.read(key: 'pin_hash')) + .thenAnswer((_) async => 'somehash'); + + final result = await service.hasPin(); + + expect(result, true); + }); + }); + + group('setPin', () { + test('stores salt and hash', () async { + when(() => mockStorage.write(key: any(named: 'key'), value: any(named: 'value'))) + .thenAnswer((_) 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); + }); + + 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; + + await service.setPin('5678'); + final secondSaltCapture = verify(() => mockStorage.write( + key: 'pin_salt', + value: captureAny(named: 'value'), + )).captured.last; + + expect(firstSaltCapture, isNot(equals(secondSaltCapture))); + }); + }); + + 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'); + + final result = await service.verifyPin('1234'); + + expect(result, 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); + + final result = await service.verifyPin('1234'); + + expect(result, 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); + + final result = await service.verifyPin('1234'); + + expect(result, 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); + + final result = await service.verifyPin('5678'); + + expect(result, false); + }); + }); + + group('clearPin', () { + test('deletes both salt and hash', () async { + when(() => mockStorage.delete(key: any(named: 'key'))) + .thenAnswer((_) async {}); + + await service.clearPin(); + + verify(() => mockStorage.delete(key: 'pin_salt')).called(1); + verify(() => mockStorage.delete(key: 'pin_hash')).called(1); + }); + }); + }); +} diff --git a/test/product_model_test.dart b/test/product_model_test.dart new file mode 100644 index 0000000..4031afc --- /dev/null +++ b/test/product_model_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltab2/models/product.dart'; + +void main() { + group('Product', () { + group('isLowStock', () { + test('returns true when stock equals threshold', () { + const product = Product( + id: '1', + name: 'Test', + category: 'Drinks', + stockQuantity: 5, + lowStockThreshold: 5, + priceInCents: 100, + ); + + expect(product.isLowStock, true); + }); + + test('returns true when stock is below threshold', () { + const product = Product( + id: '1', + name: 'Test', + category: 'Drinks', + stockQuantity: 3, + lowStockThreshold: 5, + priceInCents: 100, + ); + + expect(product.isLowStock, true); + }); + + test('returns false when stock is above threshold', () { + const product = Product( + id: '1', + name: 'Test', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 5, + priceInCents: 100, + ); + + expect(product.isLowStock, false); + }); + }); + + 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( + id: '1', + name: 'Original', + category: 'Drinks', + stockQuantity: 10, + lowStockThreshold: 5, + priceInCents: 100, + ); + + final copy = original.copyWith(name: 'Updated', stockQuantity: 20); + + expect(copy.name, 'Updated'); + expect(copy.stockQuantity, 20); + expect(copy.id, '1'); + expect(copy.category, 'Drinks'); + }); + + test('preserves original values when not specified', () { + const original = Product( + id: '1', + name: 'Test', + category: 'Food', + stockQuantity: 15, + lowStockThreshold: 3, + priceInCents: 750, + imagePath: '/path/to/image.jpg', + active: false, + ); + + final copy = original.copyWith(); + + expect(copy.id, '1'); + expect(copy.name, 'Test'); + expect(copy.category, 'Food'); + expect(copy.stockQuantity, 15); + expect(copy.lowStockThreshold, 3); + expect(copy.priceInCents, 750); + expect(copy.imagePath, '/path/to/image.jpg'); + expect(copy.active, false); + }); + }); + }); +} diff --git a/test/product_service_test.dart b/test/product_service_test.dart new file mode 100644 index 0000000..ec2637a --- /dev/null +++ b/test/product_service_test.dart @@ -0,0 +1,253 @@ +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'; + +void main() { + late AppDatabase database; + late DriftProductService service; + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + service = DriftProductService(database: database); + }); + + tearDown(() async { + await database.close(); + }); + + group('DriftProductService', () { + group('getProducts', () { + test('returns empty list when no products', () async { + final products = await service.getProducts(); + expect(products, isEmpty); + }); + + 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), + ), + ); + + final products = await service.getProducts(); + + expect(products.length, 1); + expect(products.first.name, 'Active Product'); + }); + + 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, + ), + ); + + final products = await service.getProducts(); + + expect(products.first.name, 'Apple'); + expect(products.last.name, 'Zebra'); + }); + }); + + 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, + ), + ); + + final product = await service.getProductById('prod-123'); + + expect(product, isNotNull); + expect(product!.name, 'Test Product'); + }); + + test('returns null when not exists', () async { + final product = await service.getProductById('nonexistent'); + expect(product, isNull); + }); + }); + + group('createProduct', () { + test('creates product successfully', () async { + await service.createProduct( + name: 'New Product', + category: 'Food', + stockQuantity: 20, + lowStockThreshold: 5, + priceInCents: 850, + imagePath: null, + ); + + final products = await service.getProducts(); + expect(products.length, 1); + expect(products.first.name, 'New Product'); + expect(products.first.stockQuantity, 20); + }); + }); + + 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 service.updateProduct( + const Product( + id: 'update-test', + name: 'Updated', + category: 'Food', + stockQuantity: 15, + lowStockThreshold: 3, + priceInCents: 750, + active: true, + ), + ); + + final product = await service.getProductById('update-test'); + expect(product!.name, 'Updated'); + expect(product.stockQuantity, 15); + expect(product.priceInCents, 750); + }); + }); + + 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 service.deleteProduct('delete-test'); + + final products = await database.select(database.products).get(); + expect(products, isEmpty); + }); + }); + + 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 service.decreaseStock('stock-test', 3); + + final product = await service.getProductById('stock-test'); + expect(product!.stockQuantity, 7); + }); + + test('throws when product not found', () async { + expect( + () => service.decreaseStock('nonexistent', 1), + throwsA(isA()), + ); + }); + + 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, + ), + ); + + expect( + () => service.decreaseStock('low-stock', 5), + throwsA(isA()), + ); + }); + }); + + 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 service.increaseStock('increase-test', 5); + + final product = await service.getProductById('increase-test'); + expect(product!.stockQuantity, 15); + }); + + test('throws when product not found', () async { + expect( + () => service.increaseStock('nonexistent', 1), + throwsA(isA()), + ); + }); + }); + }); +}