- Les instructions TheMealDB numérotées sur leur propre ligne ("1\n\ntexte...\n\n2\n\ntexte...") créaient des étapes parasites ne contenant qu'un chiffre — filtrées désormais (#52).
- Un ingrédient compté sans mot d'unité dans le texte source (ex. "4 Egg Yolks") laissait l'import bloqué sur "Importer" indéfiniment, sans indication visuelle de la ligne en cause — matchUnit retombe maintenant sur l'unité générique "piece" quand une quantité a été extraite, et RecipeImportForm/RecipeFormPage surlignent désormais toute ligne dont l'unité manque, avec un message explicite (#53).
- Ajout de INGREDIENT_LABEL_SYNONYMS_EN pour reconnaître des formulations alternatives fréquentes chez les sources anglophones ("vanilla pod" en plus de "vanilla bean") sans élargir INGREDIENT_LABELS_EN à un tableau pour ses ~550 entrées (#54).
- Effet de bord découvert en vérifiant #53 de bout en bout : deux lignes source résolues vers le même ingrédient catalogue (ex. "Egg Yolks"/"Eggs" -> "Œuf") faisaient planter la création en 500 (contrainte unique recipe_id+ingredient_id) au lieu d'un 400 propre. createRecipeSchema rejette maintenant les ingredientId en double, et le formulaire d'import surligne les doublons avant même de soumettre.
Vérifié de bout en bout dans le navigateur (import réel de la recette "Flan" depuis TheMealDB, jusqu'au planning) en plus des tests ajoutés.
Closes #52, #53, #54
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
import { expect } from "chai";
|
|
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
|
|
import { type TheMealDbMeal, theMealDbAdapter } from "../src/sources/the-meal-db.js";
|
|
|
|
/**
|
|
* Stubs `globalThis.fetch` for one test — no HTTP-mocking library exists
|
|
* in this codebase yet (this is the first module that talks to a real
|
|
* external network), and a single reassignable global covers the handful
|
|
* of call shapes this adapter needs without adding a new dependency.
|
|
* Restored by the `afterEach` below regardless of which test used it.
|
|
*/
|
|
function stubFetch(body: unknown, status = 200) {
|
|
globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch;
|
|
}
|
|
|
|
const baseMeal: TheMealDbMeal = {
|
|
idMeal: "52795",
|
|
strMeal: "Chicken Handi",
|
|
strMealThumb: "https://www.themealdb.com/images/media/meals/wyxwsp1486979827.jpg",
|
|
strInstructions: "Step one.\r\nStep two.\r\n\r\nStep three.",
|
|
strIngredient1: "Chicken",
|
|
strMeasure1: "1 kg",
|
|
strIngredient2: " ",
|
|
strMeasure2: "2 tbsp",
|
|
strIngredient3: "Onion",
|
|
strMeasure3: "",
|
|
};
|
|
|
|
describe("theMealDbAdapter", () => {
|
|
let originalFetch: typeof fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("declares itself as an official source, with a key/name/icon", () => {
|
|
expect(theMealDbAdapter.key).to.equal("theMealDb");
|
|
expect(theMealDbAdapter.name).to.equal("TheMealDB");
|
|
expect(theMealDbAdapter.official).to.equal(true);
|
|
expect(theMealDbAdapter.iconUrl).to.be.a("string");
|
|
});
|
|
|
|
describe("list", () => {
|
|
it("maps search results into RecipeSourceListItems", async () => {
|
|
stubFetch({
|
|
meals: [
|
|
{ idMeal: "1", strMeal: "Test Meal", strMealThumb: "https://example.test/thumb.jpg" },
|
|
],
|
|
});
|
|
|
|
const result = await theMealDbAdapter.list({ query: "test" });
|
|
|
|
expect(result.items).to.deep.equal([
|
|
{
|
|
externalId: "1",
|
|
title: "Test Meal",
|
|
picture: "https://example.test/thumb.jpg",
|
|
url: "https://www.themealdb.com/meal/1",
|
|
},
|
|
]);
|
|
expect(result.nextCursor).to.be.null;
|
|
});
|
|
|
|
it("returns an empty list when the API responds with meals: null", async () => {
|
|
stubFetch({ meals: null });
|
|
|
|
const result = await theMealDbAdapter.list({ query: "doesnotexist" });
|
|
|
|
expect(result.items).to.deep.equal([]);
|
|
expect(result.nextCursor).to.be.null;
|
|
});
|
|
|
|
it("skips a meal with no name rather than surfacing a titleless item", async () => {
|
|
stubFetch({ meals: [{ idMeal: "1", strMeal: null, strMealThumb: null }] });
|
|
|
|
const result = await theMealDbAdapter.list({ query: "x" });
|
|
|
|
expect(result.items).to.deep.equal([]);
|
|
});
|
|
|
|
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
|
stubFetch({}, 500);
|
|
|
|
try {
|
|
await theMealDbAdapter.list({ query: "x" });
|
|
expect.fail("expected list to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
}
|
|
});
|
|
|
|
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
|
|
globalThis.fetch = (async () => {
|
|
throw new Error("network down");
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
await theMealDbAdapter.list({ query: "x" });
|
|
expect.fail("expected list to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("fetchDetail", () => {
|
|
it("returns the first meal from the lookup response", async () => {
|
|
stubFetch({ meals: [baseMeal] });
|
|
|
|
const result = await theMealDbAdapter.fetchDetail("52795");
|
|
|
|
expect(result).to.deep.equal(baseMeal);
|
|
});
|
|
|
|
it("throws RecipeSourceFetchError when no meal matches the id", async () => {
|
|
stubFetch({ meals: null });
|
|
|
|
try {
|
|
await theMealDbAdapter.fetchDetail("999999");
|
|
expect.fail("expected fetchDetail to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("parse", () => {
|
|
it("maps name/picture/sourceUrl and splits instructions into steps", () => {
|
|
const parsed = theMealDbAdapter.parse(baseMeal);
|
|
|
|
expect(parsed.name).to.equal("Chicken Handi");
|
|
expect(parsed.description).to.be.null;
|
|
expect(parsed.picture).to.equal(baseMeal.strMealThumb);
|
|
expect(parsed.portions).to.be.null;
|
|
expect(parsed.sourceUrl).to.equal("https://www.themealdb.com/meal/52795");
|
|
expect(parsed.steps).to.deep.equal([
|
|
{ description: "Step one.", picture: null },
|
|
{ description: "Step two.", picture: null },
|
|
{ description: "Step three.", picture: null },
|
|
]);
|
|
});
|
|
|
|
it("skips blank ingredient slots and keeps the measure alongside the name in rawText", () => {
|
|
const parsed = theMealDbAdapter.parse(baseMeal);
|
|
|
|
expect(parsed.ingredients).to.deep.equal([
|
|
{ rawText: "1 kg Chicken", quantity: null, unit: null, name: "Chicken" },
|
|
{ rawText: "Onion", quantity: null, unit: null, name: "Onion" },
|
|
]);
|
|
});
|
|
|
|
it("drops lone step-number lines instead of turning them into bogus steps (issue #52)", () => {
|
|
const parsed = theMealDbAdapter.parse({
|
|
...baseMeal,
|
|
strInstructions:
|
|
"For the caramel, melt the sugar.\r\n\r\n2\r\n\r\nPreheat the oven.\r\n\r\n3\r\n\r\nBake it.",
|
|
});
|
|
|
|
expect(parsed.steps).to.deep.equal([
|
|
{ description: "For the caramel, melt the sugar.", picture: null },
|
|
{ description: "Preheat the oven.", picture: null },
|
|
{ description: "Bake it.", picture: null },
|
|
]);
|
|
});
|
|
|
|
it("throws RecipeSourceParseError when the meal has no name", () => {
|
|
expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw(
|
|
RecipeSourceParseError,
|
|
);
|
|
});
|
|
|
|
it("throws RecipeSourceParseError when there are no usable instructions", () => {
|
|
expect(() =>
|
|
theMealDbAdapter.parse({ ...baseMeal, strInstructions: " \r\n\r\n " }),
|
|
).to.throw(RecipeSourceParseError);
|
|
});
|
|
|
|
it("throws RecipeSourceParseError when instructions are null", () => {
|
|
expect(() => theMealDbAdapter.parse({ ...baseMeal, strInstructions: null })).to.throw(
|
|
RecipeSourceParseError,
|
|
);
|
|
});
|
|
});
|
|
});
|