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>
203 lines
7.4 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|
|
});
|