import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/core/database/app_database.dart'; import 'package:glp1_companion/features/nutrition/data/repositories/food_search_repository.dart'; import 'package:glp1_companion/features/nutrition/domain/entities/food_product.dart'; /// Recording fake implementing the [RemoteFoodDataSource] contract; each test /// configures results/behaviour and asserts how often the repository /// consulted the source. class _FakeFoodDataSource implements RemoteFoodDataSource { _FakeFoodDataSource({this.textResults, this.barcodeResults}); /// Products returned by [searchByText]; `null` simulates a source failure. final List? textResults; /// Products returned by [lookupBarcode]; `null` simulates a source failure. final List? barcodeResults; final List textQueries = []; final List barcodeQueries = []; @override Future> searchByText(String query) async { textQueries.add(query); final results = textResults; if (results == null) throw const _FakeSourceException(); return results; } @override Future> lookupBarcode(String barcode) async { barcodeQueries.add(barcode); final results = barcodeResults; if (results == null) throw const _FakeSourceException(); return results; } } class _FakeSourceException implements Exception { const _FakeSourceException(); } FoodProduct makeProduct({ required String id, String? barcode, String productName = 'Greek Yogurt', String brand = 'Fage', double calories = 90, double proteinGrams = 9, }) => FoodProduct( id: id, barcode: barcode, productName: productName, brand: brand, servingSizeGrams: 100, servingSizeUnit: 'g', calories: calories, proteinGrams: proteinGrams, carbsGrams: 4, fatGrams: 0.4, fiberGrams: null, sodiumMg: 35, imageUrl: null, lastUpdated: DateTime(2026, 10, 17, 12), ); void main() { late AppDatabase database; setUp(() { database = AppDatabase.forTesting(NativeDatabase.memory()); }); tearDown(() => database.close()); group('FoodSearchRepository text search fallback sequence', () { test('serves from the local cache without touching remote sources', () async { // Seed the cache directly, bypassing the repository. await database.upsertFoodProduct( FoodProductsTableCompanion( id: const Value('cached-1'), barcode: const Value.absent(), productName: const Value('Greek Yogurt'), brand: const Value('Fage'), servingSizeGrams: const Value(100), servingSizeUnit: const Value('g'), calories: const Value(90), proteinGrams: const Value(9), carbsGrams: const Value(4), fatGrams: const Value(0.4), fiberGrams: const Value.absent(), sodiumMg: const Value(35), imageUrl: const Value.absent(), lastUpdated: Value(DateTime(2026, 10, 17, 12)), ), ); final openFoodFacts = _FakeFoodDataSource(textResults: const []); final usda = _FakeFoodDataSource(textResults: const []); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: usda, ); final results = await repository.searchByText('yogurt'); expect(results, hasLength(1)); expect(results.single.id, 'cached-1'); expect(results.single.productName, 'Greek Yogurt'); expect(openFoodFacts.textQueries, isEmpty, reason: 'cache hit must not reach Open Food Facts'); expect(usda.textQueries, isEmpty, reason: 'cache hit must not reach USDA'); }); test('queries Open Food Facts on cache miss and caches the results', () async { final openFoodFacts = _FakeFoodDataSource( textResults: [ makeProduct(id: '3017620422003', barcode: '3017620422003'), ], ); final usda = _FakeFoodDataSource(textResults: const []); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: usda, ); final results = await repository.searchByText('nutella'); expect(results, hasLength(1)); expect(results.single.productName, 'Greek Yogurt'); expect(openFoodFacts.textQueries, ['nutella']); expect(usda.textQueries, isEmpty, reason: 'a successful OFF response must short-circuit USDA'); // Remote hits are written back into the drift cache. final cached = await database.getFoodProductByBarcode('3017620422003'); expect(cached, isNotNull); expect(cached!.productName, 'Greek Yogurt'); }); test('falls back to USDA when Open Food Facts fails, then caches', () async { final openFoodFacts = _FakeFoodDataSource(textResults: null); final usda = _FakeFoodDataSource( textResults: [ makeProduct(id: 'fdc:1105610', productName: 'Yogurt, plain'), ], ); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: usda, ); final results = await repository.searchByText('plain yogurt'); expect(results, hasLength(1)); expect(results.single.id, 'fdc:1105610'); expect(openFoodFacts.textQueries, ['plain yogurt']); expect(usda.textQueries, ['plain yogurt']); final cached = await database.getFoodProductById('fdc:1105610'); expect(cached, isNotNull); expect(cached!.proteinGrams, 9); }); test('falls back to USDA when Open Food Facts returns nothing', () async { final openFoodFacts = _FakeFoodDataSource(textResults: const []); final usda = _FakeFoodDataSource( textResults: [ makeProduct(id: 'fdc:200', productName: 'Quinoa, cooked'), ], ); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: usda, ); final results = await repository.searchByText('quinoa'); expect(results.single.productName, 'Quinoa, cooked'); expect(usda.textQueries, ['quinoa']); }); test('returns empty when every source misses', () async { final repository = FoodSearchRepository( database, openFoodFacts: _FakeFoodDataSource(textResults: const []), usdaFdc: _FakeFoodDataSource(textResults: const []), ); final results = await repository.searchByText('kale chips'); expect(results, isEmpty); }); test('second search for the same query is served from the cache', () async { final openFoodFacts = _FakeFoodDataSource( textResults: [ makeProduct(id: '43162600', productName: 'Oat Milk, Original'), ], ); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: _FakeFoodDataSource(textResults: const []), ); await repository.searchByText('oat milk'); final second = await repository.searchByText('oat milk'); expect(second, hasLength(1)); expect(openFoodFacts.textQueries, hasLength(1), reason: 'the second lookup must be a cache hit'); }); test('blank queries short-circuit without I/O', () async { final openFoodFacts = _FakeFoodDataSource(textResults: const []); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: _FakeFoodDataSource(textResults: const []), ); expect(await repository.searchByText(' '), isEmpty); expect(openFoodFacts.textQueries, isEmpty); }); }); group('FoodSearchRepository barcode lookup fallback sequence', () { test('serves a cached barcode without remote lookups', () async { await seedProductCache( database, makeProduct(id: '012345678905', barcode: '012345678905'), ); final openFoodFacts = _FakeFoodDataSource(barcodeResults: const []); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: _FakeFoodDataSource(barcodeResults: const []), ); final results = await repository.lookupBarcode('012345678905'); expect(results.single.productName, 'Greek Yogurt'); expect(openFoodFacts.barcodeQueries, isEmpty); }); test('OFF barcode hit skips the USDA fallback and caches', () async { final openFoodFacts = _FakeFoodDataSource( barcodeResults: [ makeProduct(id: '5000159407236', barcode: '5000159407236'), ], ); final usda = _FakeFoodDataSource(barcodeResults: const []); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: usda, ); final results = await repository.lookupBarcode('5000159407236'); expect(results, hasLength(1)); expect(openFoodFacts.barcodeQueries, ['5000159407236']); expect(usda.barcodeQueries, isEmpty); final cached = await database.getFoodProductByBarcode('5000159407236'); expect(cached, isNotNull); }); test('USDA fallback resolves branded GTINs OFF does not know', () async { final openFoodFacts = _FakeFoodDataSource(barcodeResults: null); final usda = _FakeFoodDataSource( barcodeResults: [ makeProduct( id: 'fdc:321345', barcode: '0012345678901', productName: 'Store Brand Bars', ), ], ); final repository = FoodSearchRepository( database, openFoodFacts: openFoodFacts, usdaFdc: usda, ); final results = await repository.lookupBarcode('0012345678901'); expect(results.single.id, 'fdc:321345'); expect(openFoodFacts.barcodeQueries, ['0012345678901']); expect(usda.barcodeQueries, ['0012345678901']); final cached = await database.getFoodProductByBarcode('0012345678901'); expect(cached, isNotNull); expect(cached!.productName, 'Store Brand Bars'); }); test('total failure throws FoodSearchUnavailableException, not empty', () async { final repository = FoodSearchRepository( database, openFoodFacts: _FakeFoodDataSource(barcodeResults: null), usdaFdc: _FakeFoodDataSource(barcodeResults: null), ); // Every source errored: the repository must surface an outage instead // of a misleading "no matches" empty list. expect( repository.lookupBarcode('9999999999999'), throwsA(isA()), ); }); test('healthy sources answering empty still return an empty list', () async { final repository = FoodSearchRepository( database, openFoodFacts: _FakeFoodDataSource(barcodeResults: const []), usdaFdc: _FakeFoodDataSource(barcodeResults: const []), ); // Both sources answered successfully with zero matches — a true // "unknown barcode", not an outage. expect(await repository.lookupBarcode('9999999999999'), isEmpty); }); }); } /// Seeds the cache with [product] using the same companion mapping the /// repository applies to remote hits. Future seedProductCache( AppDatabase database, FoodProduct product, ) => database.upsertFoodProduct( FoodProductsTableCompanion( id: Value(product.id), barcode: product.barcode == null ? const Value.absent() : Value(product.barcode), productName: Value(product.productName), brand: product.brand == null ? const Value.absent() : Value(product.brand), servingSizeGrams: Value(product.servingSizeGrams), servingSizeUnit: Value(product.servingSizeUnit), calories: Value(product.calories), proteinGrams: Value(product.proteinGrams), carbsGrams: Value(product.carbsGrams), fatGrams: Value(product.fatGrams), fiberGrams: product.fiberGrams == null ? const Value.absent() : Value(product.fiberGrams), sodiumMg: product.sodiumMg == null ? const Value.absent() : Value(product.sodiumMg), imageUrl: product.imageUrl == null ? const Value.absent() : Value(product.imageUrl), lastUpdated: Value(product.lastUpdated), ), );