glp-companion/test/features/nutrition/food_search_repository_test...

376 lines
12 KiB
Dart

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<FoodProduct>? textResults;
/// Products returned by [lookupBarcode]; `null` simulates a source failure.
final List<FoodProduct>? barcodeResults;
final List<String> textQueries = [];
final List<String> barcodeQueries = [];
@override
Future<List<FoodProduct>> searchByText(String query) async {
textQueries.add(query);
final results = textResults;
if (results == null) throw const _FakeSourceException();
return results;
}
@override
Future<List<FoodProduct>> 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<FoodSearchUnavailableException>()),
);
});
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<void> 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),
),
);