import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/features/meal_planner/domain/entities/recipe_ingredient.dart'; import 'package:glp1_companion/features/recipes/data/datasources/mealie_api_client.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; /// Phase 4B: a Mealie v1 recipe payload (ingredients, directions, nutrition) /// must normalize into the domain [Recipe] entity — identity, media, times, /// yield, structured ingredient maps, flattened step sections, and the /// nutrition display strings Mealie serves (`"512 kcal"`, `"30.5 g"`). const String _baseUrl = 'https://mealie.kellfamilyhome.com'; const String _recipeId = '6c1f2a44-9c4a-4d6e-8f6b-9a2b3c4d5e6f'; const String _slug = 'grandmas-lasagna'; /// Representative `GET /api/recipes/{slug}` detail payload. Map _detailRecipe() => { 'id': _recipeId, 'slug': _slug, 'name': "Grandma's Lasagna", 'description': 'Slow-simmered Sunday comfort.', 'prepTime': 'PT30M', 'cookTime': 'PT1H30M', 'recipeYield': '6 servings', 'recipeIngredient': [ { 'quantity': 2, 'unit': 'cup', 'food': 'ricotta', 'note': 'whole milk', }, { 'quantity': null, 'unit': null, 'food': 'baking powder', 'note': null, }, '1 pinch salt', ], 'recipeInstructions': [ { 'id': 'step-1', 'name': 'Prep', 'text': 'Boil the noodles until al dente.', 'components': [], }, { 'id': 'step-2', 'name': 'Assembly', 'text': '', 'components': [], }, 'Bake uncovered for 45 minutes.', ], 'nutrition': { 'calories': '512 kcal', 'proteinContent': '30.5 g', 'carbohydrateContent': '48 g', 'fatContent': '22 g', 'fiberContent': '6 g', }, }; /// Listing summary shape of `GET /api/recipes?perPage=-1` (no detail fields). Map _summaryRecipe() => { 'id': _recipeId, 'slug': _slug, 'name': "Grandma's Lasagna", }; void main() { group('MealieApiClient.mapRecipe', () { test('maps identity, media, times and yield onto the domain Recipe', () { final recipe = MealieApiClient.mapRecipe( _detailRecipe(), baseUrl: '$_baseUrl/', ); expect(recipe, isNotNull); // The Mealie UUID is reused as the local primary key so re-syncs // upsert the same row. expect(recipe!.id, _recipeId); expect(recipe.title, "Grandma's Lasagna"); expect(recipe.description, 'Slow-simmered Sunday comfort.'); expect(recipe.sourceUrl, '$_baseUrl/recipe/$_slug'); expect( recipe.imageUrl, '$_baseUrl/api/media/recipes/$_recipeId/images/min-original.webp', ); expect(recipe.prepTimeMinutes, 30); expect(recipe.cookTimeMinutes, 90); expect(recipe.servings, 6); }); test('maps structured and legacy string ingredients', () { final recipe = MealieApiClient.mapRecipe( _detailRecipe(), baseUrl: _baseUrl, )!; expect(recipe.ingredients, hasLength(3)); // Structured map entry: quantity/unit/food/note fields are preserved // exactly and the note rides after a comma in the raw text. final ricotta = recipe.ingredients[0]; expect(ricotta.rawText, '2 cup ricotta, whole milk'); expect(ricotta.quantity, 2); expect(ricotta.unit, 'cup'); expect(ricotta.parsedItemName, 'ricotta, whole milk'); // Null quantity/unit stay null (never coerced to 0 / ''). final powder = recipe.ingredients[1]; expect(powder.rawText, 'baking powder'); expect(powder.quantity, isNull); expect(powder.unit, isNull); expect(powder.parsedItemName, 'baking powder'); // Legacy plain-string entry falls through to the shared line parser. final salt = recipe.ingredients[2]; expect(salt.quantity, 1); expect(salt.unit, 'pinch'); expect(salt.parsedItemName, 'salt'); }); test('ingredients survive the repository JSON serialization round-trip', () { final recipe = MealieApiClient.mapRecipe( _detailRecipe(), baseUrl: _baseUrl, )!; final serialized = jsonEncode(recipe.ingredients.map((e) => e.toMap()).toList()); final restored = (jsonDecode(serialized) as List) .map( (entry) => RecipeIngredient.fromMap( Map.from(entry as Map), ), ) .toList(); expect(restored, hasLength(recipe.ingredients.length)); for (var i = 0; i < restored.length; i++) { expect(restored[i].rawText, recipe.ingredients[i].rawText); expect(restored[i].parsedItemName, recipe.ingredients[i].parsedItemName); expect(restored[i].quantity, recipe.ingredients[i].quantity); expect(restored[i].unit, recipe.ingredients[i].unit); } }); test('flattens instruction sections into ordered steps', () { final recipe = MealieApiClient.mapRecipe( _detailRecipe(), baseUrl: _baseUrl, )!; expect(recipe.instructions, [ 'Boil the noodles until al dente.', // A section without text falls back to its name. 'Assembly', 'Bake uncovered for 45 minutes.', ]); }); test('parses nutrition display strings into the MacroProfile', () { final recipe = MealieApiClient.mapRecipe( _detailRecipe(), baseUrl: _baseUrl, )!; final macros = recipe.macrosPerServing; expect(macros.calories, 512); expect(macros.proteinGrams, closeTo(30.5, 0.001)); expect(macros.carbsGrams, closeTo(48, 0.001)); expect(macros.fatGrams, closeTo(22, 0.001)); expect(macros.isEmpty, isFalse); }); test('generates a fallback id when Mealie omits it', () { final payload = { 'slug': 'no-id-lasagna', 'name': 'No-ID Lasagna', 'recipeYieldQuantity': 2, }; final recipe = MealieApiClient.mapRecipe(payload, baseUrl: _baseUrl)!; expect( RegExp( r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', ).hasMatch(recipe.id), isTrue, reason: 'a version-4 UUID is generated locally', ); expect(recipe.sourceUrl, '$_baseUrl/recipe/no-id-lasagna'); expect(recipe.servings, 2); expect(recipe.ingredients, isEmpty); expect(recipe.instructions, isEmpty); expect(recipe.macrosPerServing.isEmpty, isTrue); }); test('rejects payloads without a usable title', () { expect( MealieApiClient.mapRecipe( {'slug': 'untitled'}, baseUrl: _baseUrl, ), isNull, ); expect( MealieApiClient.mapRecipe( {'name': ' '}, baseUrl: _baseUrl, ), isNull, ); }); }); group('MealieApiClient.fetchAllRecipes', () { test( 'lists with perPage=-1 + Bearer auth, enriches by slug, and builds ' 'the §4.9.2 media/source URLs', () async { final requests = []; final client = MockClient((request) async { requests.add(request); if (request.url.path == '/api/recipes' && request.url.queryParameters['perPage'] == '-1') { return http.Response(jsonEncode([_summaryRecipe()]), 200); } if (request.url.path == '/api/recipes/$_slug') { return http.Response(jsonEncode(_detailRecipe()), 200); } return http.Response('not found', 404); }); final recipes = await MealieApiClient(client: client) .fetchAllRecipes('$_baseUrl/', 'SECRET_TOKEN'); expect(requests, hasLength(2)); expect( requests.first.url.toString(), '$_baseUrl/api/recipes?perPage=-1', ); expect(requests.first.headers['Authorization'], 'Bearer SECRET_TOKEN'); expect(requests.last.url.path, '/api/recipes/$_slug'); expect(recipes, hasLength(1)); final recipe = recipes.single; expect(recipe.id, _recipeId); expect(recipe.sourceUrl, '$_baseUrl/recipe/$_slug'); expect( recipe.imageUrl, '$_baseUrl/api/media/recipes/$_recipeId/images/min-original.webp', ); // Detail fields override the bare summary. expect(recipe.prepTimeMinutes, 30); expect(recipe.ingredients, hasLength(3)); expect(recipe.instructions, hasLength(3)); expect(recipe.macrosPerServing.calories, 512); }); test('degrades to the summary when the detail fetch fails', () async { final client = MockClient((request) async { if (request.url.queryParameters.containsKey('perPage')) { return http.Response(jsonEncode([_summaryRecipe()]), 200); } return http.Response('boom', 500); }); final recipes = await MealieApiClient(client: client) .fetchAllRecipes(_baseUrl, 'token'); expect(recipes, hasLength(1)); final recipe = recipes.single; expect(recipe.title, "Grandma's Lasagna"); expect(recipe.ingredients, isEmpty); expect(recipe.macrosPerServing.isEmpty, isTrue); }); test('throws MealieApiException when the listing is rejected', () async { final client = MockClient((request) async => http.Response('denied', 401)); await expectLater( MealieApiClient(client: client).fetchAllRecipes(_baseUrl, 'bad'), throwsA(isA()), ); }); }); group('MealieApiClient.testConnection', () { test('returns true when /api/app/about answers 200', () async { late Uri probed; final client = MockClient((request) async { probed = request.url; return http.Response('{"version": "v1.0.0"}', 200); }); final ok = await MealieApiClient(client: client) .testConnection(_baseUrl, 'TOKEN'); expect(ok, isTrue); expect(probed.toString(), '$_baseUrl/api/app/about'); }); test('falls back to the recipes probe when /about is unavailable', () async { final paths = []; final client = MockClient((request) async { paths.add(request.url.toString()); if (request.url.path == '/api/app/about') { return http.Response('forbidden', 403); } return http.Response('[]', 200); }); final ok = await MealieApiClient(client: client) .testConnection('$_baseUrl/', 'TOKEN'); expect(ok, isTrue); expect(paths, contains('$_baseUrl/api/app/about')); expect(paths, contains('$_baseUrl/api/recipes?perPage=1')); }); test('returns false when every probe fails or the server is unreachable', () async { final rejecting = MealieApiClient( client: MockClient((request) async => http.Response('nope', 401)), ); expect(await rejecting.testConnection(_baseUrl, 'TOKEN'), isFalse); final unreachable = MealieApiClient( client: MockClient((request) async => throw Exception('offline')), ); expect(await unreachable.testConnection(_baseUrl, 'TOKEN'), isFalse); // Empty inputs never hit the network. expect(await rejecting.testConnection('', 'TOKEN'), isFalse); expect(await rejecting.testConnection(_baseUrl, ' '), isFalse); }); }); group('MealieApiClient.isMealieSourceUrl', () { test('matches the configured instance and tolerates scheme drift', () { expect( MealieApiClient.isMealieSourceUrl( '$_baseUrl/recipe/$_slug', _baseUrl, ), isTrue, ); expect( MealieApiClient.isMealieSourceUrl( '$_baseUrl/recipe/$_slug', '$_baseUrl/', ), isTrue, ); // Same host, different scheme — previously synced rows stay badged. expect( MealieApiClient.isMealieSourceUrl( '$_baseUrl/recipe/$_slug', 'http://mealie.kellfamilyhome.com', ), isTrue, ); expect( MealieApiClient.isMealieSourceUrl( 'https://example.com/recipe/best-lasagna', _baseUrl, ), isFalse, ); expect( MealieApiClient.isMealieSourceUrl('$_baseUrl/g/home/recipe/x', _baseUrl), isFalse, ); expect(MealieApiClient.isMealieSourceUrl('anything', ''), isFalse); }); }); }