batchCooking/apps/api/test/recipe/recipe-tech-step-correction.test.ts
Nicolas ecf236c1a4 feat(tech-steps): distingue les corrections manuelles des détections auto
Les corrections utilisateur (via TechStepCorrectionPopover) sont
désormais écrites directement dans StepTechStep, avec une colonne
`source` ("auto" | "manual") qui les distingue des matches du
classifieur NLP :

- Migration `step_tech_step_source` ajoutant `source` (défaut "auto")
- `applyManualCorrection`/`renumberStepTechSteps` dans
  recipe-tech-step-correction.service.ts : une correction met à jour
  ou crée l'entrée StepTechStep concernée (source "manual"), la
  réponse de l'endpoint inclut désormais le techSteps à jour du step
  (SubmitTechStepCorrectionResult), pas seulement l'audit de
  correction
- backfill-tech-steps.ts préserve les entrées "manual" existantes :
  seules les entrées "auto" sont recalculées, et un nouveau match
  auto chevauchant une correction manuelle est ignoré plutôt
  qu'inséré en doublon — vérifié en base réelle (une correction
  manuelle survit intacte à un backfill complet)
- Le front distingue visuellement les deux (StepDescription.tsx,
  recipes.scss : `.step-tech-step--manual`, couleur Turmeric au lieu
  de Basil), avec un tooltip "(correction manuelle)" et un indicateur
  de découvrabilité de la fonctionnalité dans RecipeDetailPanel

Corrige aussi deux bugs trouvés en testant en conditions réelles :
- StepDescription.tsx : le clic sur un highlight existant lisait la
  variable `offset` (mutable, partagée par la boucle) au lieu d'une
  valeur capturée, envoyant un `end` erroné (fin de la description
  entière au lieu du span du mot cliqué)
- backfill-tech-steps.ts : le garde `import.meta.url ===
  file://${process.argv[1]}` ne matche jamais sur Windows (chemins à
  antislash), le script ne faisait donc rien en exécution directe ;
  remplacé par `pathToFileURL(process.argv[1]).href`

335 tests apps/api passants, 40/40 composants Cypress, 75/76 e2e
Cypress (1 flake pré-existant sans rapport, non touché ici).
2026-08-22 12:15:47 +02:00

280 lines
12 KiB
TypeScript

import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../../src/app.js";
import { prisma } from "../../src/db/prisma.js";
import { resetDatabase } from "../../test-support/reset-db.js";
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
async function techStepId(key: string): Promise<number> {
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
return techStep.id;
}
describe("Recipe tech-step corrections", () => {
const app = createApp();
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profileId: res.body.id };
}
/** A `PUBLIC` recipe with one step — every viewer can see this, so most tests below don't need to juggle visibility on top of the correction logic itself. */
async function createPublicRecipeWithStep(
authorId: number,
description = "Faire mijoter la sauce.",
): Promise<{ recipeId: number; stepId: number }> {
const recipe = await prisma.recipe.create({
data: {
name: "Recette",
authorId,
visibility: "PUBLIC",
portions: 4,
steps: { create: [{ description, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
return { recipeId: recipe.id, stepId: step.id };
}
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /recipes/:id/steps/:stepId/corrections", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const { profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await request(app)
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("records a correction adding a missing technique (no previousTechStepId), and applies it immediately to the step's own techSteps", async () => {
const { agent, profileId } = await signup();
// "Faire mijoter la sauce." names no technique the classifier itself
// registers a bare-word anchor for at this exact span in isolation
// (see tech-step-training-data.ts) — irrelevant here either way,
// since this test's whole point is the *manual* addition, not
// whatever the classifier does or doesn't auto-detect for it.
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.equal(null);
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.start).to.equal(6);
expect(res.body.correction.end).to.equal(13);
// The step's real technique sequence reflects the correction right
// away — not just the permanent audit record above (see
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
expect(res.body.techSteps).to.deep.equal([
{ techStep: { id: simmerId, key: "simmer" }, start: 6, end: 13, source: "manual" },
]);
});
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
// First correction creates the "manual" entry this test then relabels
// — exercises the UPDATE branch of `applyManualCorrection`, not the
// INSERT one the previous test already covers.
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
// Still exactly one entry — the relabel updated the existing row
// rather than adding a second one alongside it.
expect(res.body.techSteps).to.deep.equal([
{ techStep: { id: boilId, key: "boil" }, start: 6, end: 13, source: "manual" },
]);
});
it("deletes the matching techSteps entry when correctedTechStepId is null (a removal)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: null });
expect(res.status).to.equal(201);
expect(res.body.correction.correctedTechStep).to.equal(null);
expect(res.body.techSteps).to.deep.equal([]);
});
it("is not restricted to the recipe's author — any viewer who can see it may correct it", async () => {
const { profileId: authorId } = await signup();
const { agent: otherAgent } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(authorId);
const res = await otherAgent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(201);
});
it("rejects both previousTechStepId and correctedTechStepId absent with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5 });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects end <= start with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 5, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
const { agent, profileId } = await signup();
const description = "Court.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: description.length + 10,
correctedTechStepId: await techStepId("simmer"),
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
it("rejects an unknown correctedTechStepId with 404 TECH_STEP_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: 999_999 });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
});
it("rejects a step that exists but isn't visible to the viewer with 404 RECIPE_NOT_FOUND", async () => {
const { profileId: authorId } = await signup();
const { agent: otherAgent } = await signup();
const recipe = await prisma.recipe.create({
data: {
name: "Secrète",
authorId,
portions: 4,
steps: { create: [{ description: "Faire mijoter la sauce.", order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
const res = await otherAgent
.post(`/recipes/${recipe.id}/steps/${step.id}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects a stepId that belongs to a different recipe than the URL's :id with 404 STEP_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId: otherRecipeId } = await createPublicRecipeWithStep(profileId);
const { stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${otherRecipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.STEP_NOT_FOUND);
});
});
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
it("returns every correction submitted for the step, most recent first", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
});
it("returns an empty list when nothing has been submitted yet", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
});
});