376 lines
12 KiB
Dart
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),
|
|
),
|
|
);
|
|
|