batchCooking/apps/api/test/recipe-sources/quitoque.test.ts
Nicolas 4906accd85
All checks were successful
CI / build (push) Successful in 3m28s
CI / lint (push) Successful in 3m33s
CI / intent-service-test (push) Successful in 4m54s
CI / e2e (push) Successful in 7m20s
CI / test (push) Successful in 15m33s
feat(sources): ajoute quitoque.fr comme source de recettes importable
5e adaptateur, calqué sur `marmiton.ts` (le plus fin des wrappers
scrapés) : `fetchDetail`/`parse` délèguent tels quels à
`jsonLdRecipeAdapter` — une page recette quitoque.fr porte un `Recipe`
schema.org, seul élément d'un tableau JSON-LD de premier niveau, déjà géré
nativement par `findRecipeNode`. Seul `list()` est spécifique :
`GET /recettes?page=N` (page 1 implicite si curseur absent), scrape des
cartes `<a class="card__content_header" href="/recettes/…">` (image réelle
dans `data-src`, `src` n'étant qu'un placeholder lazy-load ; `href`
relatif résolu contre l'origine). Extraction agnostique à l'ordre des
attributs.

Pas de vraie recherche serveur sur le site → `query` est un filtre
sous-chaîne « best effort » sur les titres de la page courante (même
lacune assumée que `750g.ts`). Fin de liste = page sans aucune carte
(comme `marmiton.ts`). `rekeySourceError` dupliqué localement (750g /
manger-bouger en ont chacun une copie).

Enregistré dans `registerAllRecipeSources()`.

Tests : `quitoque.test.ts` (`stubFetchHtml`, pas de base) — scrape des
cartes, pagination `?page=N`, filtre par query, fin de liste, erreurs
re-labellisées `quitoque`, `parse` sur un JSON-LD tableau-de-premier-
niveau. Vérifié localement, 9/9. + `sources-index.test.ts` couvre
l'enregistrement.

Docs : section `quitoque.ts` dans `backend-architecture.md` (+ correction
de la ligne « n'enregistre que TheMealDB », devenue fausse).

Closes #37

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 01:11:48 +02:00

203 lines
7.4 KiB
TypeScript

import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { quitoqueAdapter } from "../../src/sources/quitoque.js";
/** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`750g.test.ts`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/** Like {@link stubFetchHtml} but records the URL each call was made with — for asserting `list()`'s pagination. */
function stubFetchHtmlCapturing(html: string): { calls: string[] } {
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
calls.push(typeof input === "string" ? input : input.toString());
return new Response(html, { status: 200 });
}) as typeof fetch;
return { calls };
}
/** A `/recettes` listing page carrying `count` cards, each a single `<a class="card__content_header">` wrapping an `<img alt data-src>`. */
function listingHtml(cards: { slug: string; title: string; image: string }[]): string {
const anchors = cards
.map(
(card) =>
`<article><a class="card__content_header" href="/recettes/${card.slug}">` +
`<img src="/placeholder.svg" alt="${card.title}" data-src="${card.image}" />` +
`</a></article>`,
)
.join("\n");
return `<!doctype html><html><body><main>${anchors}</main></body></html>`;
}
/** A quitoque.fr recipe page: a plain schema.org `Recipe` as the sole element of a top-level JSON-LD array. */
function detailHtml(): string {
const payload = [
{
"@context": "https://schema.org",
"@type": "Recipe",
name: "Mijoté de lentilles au lait de coco",
description: "Un plat végétarien réconfortant.",
image: "https://www.quitoque.fr/img/mijote.jpg",
recipeYield: "2",
recipeIngredient: ["150 g lentilles vertes (Bio)", "1 gousse d'ail", "0.5 citron jaune"],
recipeInstructions: [
{ "@type": "HowToStep", name: "1. Les légumes", text: "Émincez l'ail." },
{ "@type": "HowToStep", name: "2. La cuisson", text: "Faites mijoter 25 minutes." },
],
},
];
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
describe("quitoqueAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(quitoqueAdapter.key).to.equal("quitoque");
expect(quitoqueAdapter.name).to.equal("Quitoque");
expect(quitoqueAdapter.official).to.equal(false);
expect(quitoqueAdapter.iconUrl).to.be.a("string");
expect(quitoqueAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("scrapes each card into a RecipeSourceListItem (real image from data-src, absolute url) and offers a next page", async () => {
stubFetchHtml(
listingHtml([
{
slug: "mijote-lentilles",
title: "Mijoté de lentilles",
image: "https://img.test/1.jpg",
},
{ slug: "curry-legumes", title: "Curry de légumes", image: "https://img.test/2.jpg" },
]),
);
const result = await quitoqueAdapter.list({});
expect(result.items).to.deep.equal([
{
externalId: "https://www.quitoque.fr/recettes/mijote-lentilles",
title: "Mijoté de lentilles",
picture: "https://img.test/1.jpg",
url: "https://www.quitoque.fr/recettes/mijote-lentilles",
},
{
externalId: "https://www.quitoque.fr/recettes/curry-legumes",
title: "Curry de légumes",
picture: "https://img.test/2.jpg",
url: "https://www.quitoque.fr/recettes/curry-legumes",
},
]);
expect(result.nextCursor).to.equal("2");
});
it("treats a page with no cards as the end of the list", async () => {
stubFetchHtml("<!doctype html><html><body><main></main></body></html>");
const result = await quitoqueAdapter.list({ cursor: "9" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("follows the cursor to `/recettes?page=N`", async () => {
const { calls } = stubFetchHtmlCapturing(
listingHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
);
await quitoqueAdapter.list({ cursor: "3" });
expect(calls).to.deep.equal(["https://www.quitoque.fr/recettes?page=3"]);
});
it("best-effort filters a page's cards by a query substring, but still advances if the raw page had cards", async () => {
stubFetchHtml(
listingHtml([
{ slug: "poulet-curry", title: "Poulet au curry", image: "https://img.test/p.jpg" },
{ slug: "boeuf-carottes", title: "Bœuf carottes", image: "https://img.test/b.jpg" },
]),
);
const result = await quitoqueAdapter.list({ query: "poulet" });
expect(result.items.map((item) => item.title)).to.deep.equal(["Poulet au curry"]);
// The page did have cards, so the next page might match too.
expect(result.nextCursor).to.equal("2");
});
it("throws RecipeSourceFetchError keyed 'quitoque' on a non-2xx listing response", async () => {
stubFetchHtml("", 503);
try {
await quitoqueAdapter.list({});
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque");
}
});
});
describe("fetchDetail / parse", () => {
it("parses a quitoque.fr recipe page's top-level-array JSON-LD into a ParsedRecipe", async () => {
stubFetchHtml(detailHtml());
const raw = await quitoqueAdapter.fetchDetail(
"https://www.quitoque.fr/recettes/mijote-lentilles",
);
const parsed = quitoqueAdapter.parse(raw);
expect(parsed.name).to.equal("Mijoté de lentilles au lait de coco");
expect(parsed.portions).to.equal(2);
expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([
"150 g lentilles vertes (Bio)",
"1 gousse d'ail",
"0.5 citron jaune",
]);
expect(parsed.steps.map((s) => s.description)).to.deep.equal([
"Émincez l'ail.",
"Faites mijoter 25 minutes.",
]);
});
it("re-keys a delegated RecipeSourceFetchError to 'quitoque' when the detail page 404s", async () => {
stubFetchHtml("", 404);
try {
await quitoqueAdapter.fetchDetail("https://www.quitoque.fr/recettes/gone");
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque");
}
});
it("re-keys a delegated RecipeSourceParseError to 'quitoque' when a page has no Recipe JSON-LD", () => {
try {
quitoqueAdapter.parse({
html: "<!doctype html><html><body>rien ici</body></html>",
url: "https://www.quitoque.fr/recettes/vide",
});
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("quitoque");
}
});
});
});