384 lines
13 KiB
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);
|
|
});
|
|
});
|
|
}
|