batchCooking/apps/web/cypress/e2e/recipe-sources.ts
Nicolas c48625cb0c
Some checks failed
CI / lint (push) Successful in 3m0s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 7m41s
CI / test (push) Has been cancelled
fix(recipes): stoppe le scroll infini des sources au lieu de boucler
Deux volets de l'issue #38.

**Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a
que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un
scroll normal (`hasMore` reste `true`). Il figeait la toute première
closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur
périmé — la liste s'allongeait en boucle avec les mêmes éléments et
`nextCursor` n'atteignait jamais son vrai `null`.
- `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à
  chaque rendu : l'observer appelle toujours la closure fraîche, sans
  réabonnement inutile (intention d'origine du `biome-ignore` conservée,
  le commentaire n'étant plus nécessaire).
- `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite
  « cette page n'a rien apporté de nouveau » comme une fin de liste même
  si la source renvoie encore un `nextCursor` — garde-fou contre une
  éventuelle heuristique de fin imparfaite côté adaptateur.

**Couverture recherche vide** — `theMealDbAdapter.list()` tapait
`/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes
(`nextCursor` toujours `null`). Une requête vide déroule désormais la
liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une
catégorie par pas de curseur, pour un vrai « tout parcourir ».

Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts
réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour
la marche par catégories (première page + curseur, suivi du curseur,
arrêt sur la dernière catégorie, page vide au-delà, requête blanche =
requête vide).

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:20:43 +02:00

259 lines
8.7 KiB
TypeScript

import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// behavior against a real database (see test/sources.test.ts).
//
// "the sources reference list has options"/"the household's enabled
// sources are empty" resolve from cypress/support/step_definitions/ (the
// preprocessor's step-lookup is *not* global across cypress/e2e/ — only a
// feature's own same-named file/directory plus that shared folder are
// searched, see its error message when a step isn't found). recipes.ts
// sits directly in cypress/e2e/ (not that shared folder), so its own
// "the disliked ingredients list is empty"/"the recipe catalog
// contains"/"recipe 2's detail is available" are scoped to recipes.feature
// only — this file redeclares its own minimal equivalents rather than
// relocating shared infra, the same "each spec's own self-contained
// fixtures" precedent recipes.cy.ts already sets alongside recipes.ts.
Given("the disliked ingredients list is empty", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
});
Given("the recipe catalog contains nothing", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
});
Given("recipe 2's detail is available", () => {
cy.intercept("GET", "**/recipes/2", {
statusCode: 200,
body: {
id: 2,
name: "Omelette",
description: null,
picture: null,
portions: 2,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{
id: 1,
description: "Cuire à la poêle.",
picture: null,
order: 1,
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
},
],
},
});
});
Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
});
Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200,
body: {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
});
/**
* A second, distinct item from `Given("browsing TheMealDB returns some
* items")`'s page-1 pair — used by the infinite-scroll/retry scenarios
* below, which need to tell "the item that only shows up once the *next*
* page has loaded" apart from what's already visible on page 1.
*/
const BEEF_WELLINGTON_ITEM = {
externalId: "77123",
title: "Beef Wellington",
picture: null,
url: "https://www.themealdb.com/meal/77123",
alreadyImported: false,
recipeId: null,
};
const CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 = {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: "2",
};
Given("browsing TheMealDB returns two pages of items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
const isNextPage = req.url.includes("cursor=");
req.reply({
statusCode: 200,
body: isNextPage
? { items: [BEEF_WELLINGTON_ITEM], nextCursor: null }
: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1,
});
});
});
/**
* Three real pages, each addressed by a distinct `cursor` value the mock
* actually parses (not just "does the URL contain `cursor=`"). Records
* every cursor it serves so the scenario can assert none was requested
* twice — the tell-tale of the issue #38 loop, where a stale closure
* re-requests the same page forever instead of advancing.
*/
const threePageCursorsRequested: string[] = [];
Given("browsing TheMealDB returns three pages, each addressed by its own cursor", () => {
threePageCursorsRequested.length = 0;
const item = (externalId: string, title: string) => ({
externalId,
title,
picture: null,
url: `https://www.themealdb.com/meal/${externalId}`,
alreadyImported: false,
recipeId: null,
});
const pages: Record<string, { items: unknown[]; nextCursor: string | null }> = {
"": { items: [item("p1", "Page One Dish")], nextCursor: "cursor-2" },
"cursor-2": { items: [item("p2", "Page Two Dish")], nextCursor: "cursor-3" },
"cursor-3": { items: [item("p3", "Page Three Dish")], nextCursor: null },
};
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
const cursor = new URL(req.url).searchParams.get("cursor") ?? "";
threePageCursorsRequested.push(cursor);
req.reply({ statusCode: 200, body: pages[cursor] ?? { items: [], nextCursor: null } });
});
});
Then("no browse cursor should have been requested more than once", () => {
// Let the panel settle (auto-scroll + one-page-ahead prefetch) before
// asserting. A looping IntersectionObserver would re-hit "" or "cursor-2"
// here; the latest-ref fix keeps each cursor to a single request.
cy.wait(600).then(() => {
const seen = [...threePageCursorsRequested];
expect(seen.length, `cursors requested: ${JSON.stringify(seen)}`).to.equal(new Set(seen).size);
});
});
// The panel prefetches the next page as soon as page 1 is on screen (before
// anyone's actually waited on it), so the *first* request for it is that
// prefetch — this is what actually fails "once", not a request triggered by
// a click. `handleLoadMore`'s own retry then makes a genuinely fresh
// request (see its own doc comment on why a failed prefetch gets cleared),
// which is the one that succeeds here.
Given("browsing TheMealDB's next page fails once, then succeeds", () => {
let nextPageAttempts = 0;
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
if (!req.url.includes("cursor=")) {
req.reply({ statusCode: 200, body: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 });
return;
}
nextPageAttempts += 1;
if (nextPageAttempts === 1) {
req.reply({ statusCode: 500, body: {} });
} else {
req.reply({ statusCode: 200, body: { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } });
}
});
});
Then("I should see a message to retry loading more", () => {
cy.contains(".recipes-page__status--error", "Réessayer").should("be.visible");
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name: "Fish Pie",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://www.themealdb.com/meal/9999",
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: null,
},
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
],
steps: [
{
description: "Cuire à la poêle.",
picture: null,
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
},
],
},
});
});
Then("I should see the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).should("be.visible");
});
When("I click the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).click();
});
Then("the source item {string} should be marked as already imported", (title: string) => {
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
});
// Browsing a source (outside of adding-to-planning, `RecipePickerDialog`'s
// own scenarios in planning.ts) never imports anything — the only action
// this preview offers is a discreet way out to the item's own page.
Then("I should see a discreet link to the item's original page", () => {
cy.get(".recipe-detail-panel__source-link").should("be.visible");
});