glp-companion/test/features/recipes/mealie_parser_test.dart

384 lines
13 KiB
Dart

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<String, dynamic> _detailRecipe() => <String, dynamic>{
'id': _recipeId,
'slug': _slug,
'name': "Grandma's Lasagna",
'description': 'Slow-simmered Sunday comfort.',
'prepTime': 'PT30M',
'cookTime': 'PT1H30M',
'recipeYield': '6 servings',
'recipeIngredient': <Object?>[
<String, Object?>{
'quantity': 2,
'unit': 'cup',
'food': 'ricotta',
'note': 'whole milk',
},
<String, Object?>{
'quantity': null,
'unit': null,
'food': 'baking powder',
'note': null,
},
'1 pinch salt',
],
'recipeInstructions': <Object?>[
<String, Object?>{
'id': 'step-1',
'name': 'Prep',
'text': 'Boil the noodles until al dente.',
'components': <Object?>[],
},
<String, Object?>{
'id': 'step-2',
'name': 'Assembly',
'text': '',
'components': <Object?>[],
},
'Bake uncovered for 45 minutes.',
],
'nutrition': <String, Object?>{
'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<String, dynamic> _summaryRecipe() => <String, dynamic>{
'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<dynamic>)
.map(
(entry) => RecipeIngredient.fromMap(
Map<String, Object?>.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, <String>[
'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 = <String, dynamic>{
'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(
<String, dynamic>{'slug': 'untitled'},
baseUrl: _baseUrl,
),
isNull,
);
expect(
MealieApiClient.mapRecipe(
<String, dynamic>{'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 = <http.Request>[];
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<MealieApiException>()),
);
});
});
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 = <String>[];
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);
});
});
}