import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart';
/// Rich schema.org/JSON-LD fixture: `@graph` container, mixed
/// `recipeInstructions` shapes (string, `HowToStep`, nested `HowToSection`),
/// ISO-8601 durations, list-valued `image`, and string-quantity nutrition.
const String _jsonLdHtml = '''
High-Protein Chicken Bowl - Example Kitchen
''';
void main() {
late ScrapeRecipeFromUrl useCase;
setUp(() => useCase = ScrapeRecipeFromUrl());
group('JSON-LD recipe extraction', () {
test('parses a full @graph-hosted schema.org Recipe', () {
final recipe = useCase.parseHtml(
_jsonLdHtml,
sourceUrl: 'https://example.com/chicken-bowl',
);
expect(recipe, isNotNull);
expect(recipe!.id, isNotEmpty);
expect(recipe.title, 'High-Protein Chicken Bowl');
expect(recipe.sourceUrl, 'https://example.com/chicken-bowl');
expect(recipe.description, 'A quick post-workout bowl.');
expect(recipe.imageUrl, 'https://example.com/chicken-1.jpg');
expect(recipe.prepTimeMinutes, 15);
expect(recipe.cookTimeMinutes, 35);
expect(recipe.servings, 4);
});
test('extracts macros from the nutrition block', () {
final recipe = useCase.parseHtml(
_jsonLdHtml,
sourceUrl: 'https://example.com/chicken-bowl',
)!;
expect(recipe.macrosPerServing.calories, 420);
expect(recipe.macrosPerServing.proteinGrams, 38);
expect(recipe.macrosPerServing.carbsGrams, 45);
expect(recipe.macrosPerServing.fatGrams, 12);
});
test('parses ingredient quantities, units, and item names', () {
final recipe = useCase.parseHtml(
_jsonLdHtml,
sourceUrl: 'https://example.com/chicken-bowl',
)!;
expect(recipe.ingredients, hasLength(4));
final chicken = recipe.ingredients[0];
expect(chicken.rawText, '2 chicken breasts');
expect(chicken.quantity, 2);
expect(chicken.unit, isNull); // "breasts" is not a known unit
expect(chicken.parsedItemName, 'chicken breasts');
final rice = recipe.ingredients[1];
expect(rice.quantity, 1.5); // mixed number "1 1/2"
expect(rice.unit, 'cups');
expect(rice.parsedItemName, 'cooked rice');
final salt = recipe.ingredients[2];
expect(salt.quantity, 0.25);
expect(salt.unit, 'tsp');
expect(salt.parsedItemName, 'salt');
final oil = recipe.ingredients[3];
expect(oil.quantity, 2);
expect(oil.unit, 'tbsp');
expect(oil.parsedItemName, 'olive oil');
});
test('flattens strings, HowToStep objects, and HowToSections', () {
final recipe = useCase.parseHtml(
_jsonLdHtml,
sourceUrl: 'https://example.com/chicken-bowl',
)!;
expect(recipe.instructions, [
'Season the chicken.',
'Grill for 6 minutes per side.',
'Rest, then slice and serve.',
]);
});
});
group('JSON-LD navigation variants', () {
test('finds a root-level Recipe object without @graph', () {
const html = '''
''';
final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/a');
expect(recipe, isNotNull);
expect(recipe!.title, 'Root Recipe');
expect(recipe.macrosPerServing.calories, 300);
expect(recipe.instructions, ['Mix and eat.']);
// No recipeYield → default servings.
expect(recipe.servings, useCase.defaultServings);
});
test('finds a Recipe inside a top-level JSON array with list @type', () {
const html = '''
''';
final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/b');
expect(recipe, isNotNull);
expect(recipe!.title, 'Array Recipe');
});
test('skips malformed JSON-LD and falls through to OpenGraph', () {
const html = '''
''';
final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/c');
expect(recipe, isNotNull);
expect(recipe!.title, 'OG Rescued Title');
expect(recipe.description, 'A description.');
expect(recipe.imageUrl, 'https://x.test/img.png');
expect(recipe.ingredients, isEmpty);
expect(recipe.macrosPerServing.isEmpty, isTrue);
});
});
group('OpenGraph fallback', () {
test('uses og tags when JSON-LD is absent', () {
const html = '''
''';
final recipe = useCase.parseHtml(html, sourceUrl: 'https://soup.test/p');
expect(recipe, isNotNull);
expect(recipe!.title, 'Simple OG Soup');
expect(recipe.imageUrl, 'https://soup.test/soup.jpg');
expect(recipe.description, 'Warm and cozy.');
expect(recipe.ingredients, isEmpty);
expect(recipe.instructions, isEmpty);
expect(recipe.macrosPerServing.isEmpty, isTrue);
expect(recipe.prepTimeMinutes, 0);
expect(recipe.cookTimeMinutes, 0);
});
test('returns null when neither JSON-LD nor og:title exist', () {
const html = 'Just a page'
'hello
';
expect(
useCase.parseHtml(html, sourceUrl: 'https://none.test/'),
isNull,
);
});
});
group('execute()', () {
test('downloads via the injected fetcher and parses', () async {
var requestedUrl = Uri.parse('about:blank');
final scraping = ScrapeRecipeFromUrl(
fetcher: (url) async {
requestedUrl = url;
return _jsonLdHtml;
},
);
final recipe = await scraping('https://example.com/chicken-bowl');
expect(requestedUrl.toString(), 'https://example.com/chicken-bowl');
expect(recipe.title, 'High-Protein Chicken Bowl');
expect(recipe.macrosPerServing.proteinGrams, 38);
});
test('rejects non-http(s) and malformed URLs', () async {
final scraping = ScrapeRecipeFromUrl(
fetcher: (_) async => _jsonLdHtml,
);
await expectLater(
scraping('ftp://example.com/file'),
throwsA(isA()),
);
await expectLater(
scraping('not a url'),
throwsA(isA()),
);
});
test('throws when no recipe metadata is present', () async {
final scraping = ScrapeRecipeFromUrl(
fetcher: (_) async =>
'Nothing here',
);
await expectLater(
scraping('https://example.com/blank'),
throwsA(
isA().having(
(e) => e.message,
'message',
contains('No schema.org Recipe'),
),
),
);
});
test('propagates fetcher failures as RecipeScrapeException', () async {
final scraping = ScrapeRecipeFromUrl(
fetcher: (_) async => throw Exception('connection reset'),
);
await expectLater(
scraping('https://example.com/flaky'),
throwsA(isA()),
);
});
});
group('Real-world sample fixtures', () {
/// Loads a captured page shape from `test/fixtures/recipes/`.
String fixture(String name) =>
File('test/fixtures/recipes/$name').readAsStringSync();
test('parses a BBC Good Food-style root-level Recipe payload', () {
final recipe = useCase.parseHtml(
fixture('bbc_good_food_style.html'),
sourceUrl: 'https://www.bbcgoodfood.com/recipes/classic-chicken-curry',
);
expect(recipe, isNotNull);
expect(recipe!.title, 'Classic chicken curry');
expect(recipe.description, startsWith('A simple, healthy curry'));
expect(
recipe.imageUrl,
endsWith('styles/hero/public/curry.jpg'),
);
expect(recipe.prepTimeMinutes, 15);
expect(recipe.cookTimeMinutes, 35);
expect(recipe.servings, 4);
// Published nutrition strings carry units; numeric parsing strips them.
expect(recipe.macrosPerServing.calories, 387);
expect(recipe.macrosPerServing.proteinGrams, 34);
expect(recipe.macrosPerServing.carbsGrams, 20);
expect(recipe.macrosPerServing.fatGrams, 14);
expect(recipe.ingredients, hasLength(5));
final curryPaste = recipe.ingredients[2];
expect(curryPaste.quantity, 3);
expect(curryPaste.unit, 'tbsp');
expect(curryPaste.parsedItemName, 'curry paste');
final yogurt = recipe.ingredients[3];
expect(yogurt.quantity, 100);
expect(yogurt.unit, 'g');
expect(yogurt.parsedItemName, 'yogurt');
expect(recipe.instructions, hasLength(3));
expect(recipe.instructions.first, 'Fry the onion until golden.');
});
test('skips non-recipe @graph scripts and parses the complex one', () {
final recipe = useCase.parseHtml(
fixture('allrecipes_style.html'),
sourceUrl: 'https://example.eats/recipes/turkey-meatballs',
);
expect(recipe, isNotNull);
expect(recipe!.title, 'Weeknight Turkey Meatballs');
// List-valued yield: ["6 servings"].
expect(recipe.servings, 6);
expect(recipe.prepTimeMinutes, 20);
expect(recipe.cookTimeMinutes, 25);
// First entry of the mixed string/object image array wins.
expect(
recipe.imageUrl,
'https://example.eats/img/turkey-meatballs-1000.jpg',
);
expect(recipe.macrosPerServing.calories, 321);
expect(recipe.macrosPerServing.proteinGrams, 29);
expect(recipe.ingredients, hasLength(5));
// Decimal metric weight in imperial clothing: "1.25 lb ground turkey".
expect(recipe.ingredients[0].quantity, 1.25);
expect(recipe.ingredients[0].unit, 'lb');
expect(recipe.ingredients[0].parsedItemName, 'ground turkey');
expect(recipe.ingredients[1].quantity, 0.5);
expect(recipe.ingredients[1].unit, 'cup');
// Nested HowToSections flatten into plain steps in document order.
expect(recipe.instructions, [
'Heat the oven to 400°F and line a sheet pan.',
'Mix the turkey, breadcrumbs, and egg; roll into 18 balls.',
'Bake for 18 minutes until browned.',
'Simmer the garlic and tomatoes for 10 minutes.',
]);
});
});
group('download hardening', () {
test('native headers carry a browser User-Agent (anti-403 hardening)', () {
final headers = ScrapeRecipeFromUrl.buildScrapeHeaders(isWeb: false);
expect(headers['User-Agent'], startsWith('Mozilla/5.0'));
expect(headers['Accept'], contains('text/html'));
expect(headers['Accept-Language'], 'en-US,en;q=0.9');
});
test('web headers omit the forbidden User-Agent header', () {
final headers = ScrapeRecipeFromUrl.buildScrapeHeaders(isWeb: true);
expect(headers.containsKey('User-Agent'), isFalse);
expect(headers['Accept'], contains('text/html'));
});
test('proxy wrapping percent-encodes the target URL', () {
final wrapped = resolveScrapeRequestUri(
Uri.parse('https://example.com/recipes/chicken?x=1'),
proxyUrl: 'https://proxy.example/fetch',
);
expect(
wrapped.toString(),
'https://proxy.example/fetch'
'?url=https%3A%2F%2Fexample.com%2Frecipes%2Fchicken%3Fx%3D1',
);
});
test('proxy wrapping appends with & when the proxy has its own query', () {
final wrapped = resolveScrapeRequestUri(
Uri.parse('https://example.com/r'),
proxyUrl: 'https://proxy.example/fetch?token=abc',
);
expect(
wrapped.toString(),
'https://proxy.example/fetch?token=abc'
'&url=https%3A%2F%2Fexample.com%2Fr',
);
});
test('without a proxy the target URL is requested directly', () {
final target = Uri.parse('https://example.com/r');
expect(resolveScrapeRequestUri(target, proxyUrl: ''), target);
expect(resolveScrapeRequestUri(target, proxyUrl: ' '), target);
});
test('execute rethrows RecipeScrapeException from the fetcher verbatim',
() async {
final useCase = ScrapeRecipeFromUrl(
fetcher: (url) async =>
throw RecipeScrapeException('Download failed (HTTP 403).'),
);
await expectLater(
useCase.execute('https://example.com/r'),
throwsA(
isA().having(
(e) => e.message,
'message',
'Download failed (HTTP 403).',
),
),
);
});
test('execute wraps unexpected fetcher errors with download context',
() async {
final useCase = ScrapeRecipeFromUrl(
fetcher: (url) async => throw Exception('socket closed'),
);
await expectLater(
useCase.execute('https://example.com/r'),
throwsA(
isA().having(
(e) => e.message,
'message',
contains('Could not download the page'),
),
),
);
});
});
}