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'), ), ), ); }); }); }