Socle backend, entièrement testable en CI, pour le tutoriel guidé. Le moteur front (driver.js), l'instrumentation `data-tour-*` et le CRUD admin viendront ensuite. Schéma (`schema.prisma` + migration `20260906120000_tour_scenarios`) — les 4 modèles de #44, qui révisent le registre statique prévu par #42 : - `TourTarget` (`key` PK + `description`) : catalogue des cibles de surlignage valides, seedé, comme `TechStep`/`Unit`. - `TourScenario` (`key` stable, `name`, `enabled`) : un scénario rejouable. - `TourScenarioStep` (`order`, `route?`, `targetKey`, `title`, `content`) : `title`/`content` sont du **texte libre**, pas des clés i18n (#44 : contenu administrable → texte, comme le nom d'une recette). - `TourProgress` (`userProfileId` + `scenarioId`, PK composite) : présence d'une ligne = scénario vu (terminé *ou* passé). Seed (`reference-seed-data.ts`) : les 9 `TourTarget` correspondant aux `data-tour-*` à poser côté front, + les 2 scénarios de #42 (`general`, `techStepMetadata`) **créés seulement s'ils sont absents** (un admin en devient propriétaire ensuite). `techStepMetadata` reste court pour l'instant — #36 (popover qui se ferme en pleine sélection) est ouvert, v1 ne couvre que les gestes qui marchent déjà. API : - `GET /reference/tours` (public) → scénarios **activés** + étapes ordonnées, forme consommée directement par le futur `useTour()`. - `GET /profile/tours` → `{ completedTourKeys: string[] }`. - `POST /profile/tours/:tourKey/complete` → upsert idempotent ; corps vide = terminé, `{ skipped: true }` = passé. Émet `analytics.recordEvent` `tour.completed` / `tour.skipped` (le dashboard admin agrège déjà tout type d'`AnalyticsEvent` génériquement — analytics de #44 quasi gratuites). 404 `TOUR_NOT_FOUND` (nouveau code 4053) sur une clé inconnue/désactivée. `reset-db.ts` truncate les 4 nouvelles tables (reseedées juste après). Tests Mocha : `GET /reference/tours` (scénarios + étapes ordonnées, exclusion d'un scénario désactivé) ; `/profile/tours` GET+POST (upsert idempotent, `skipped`, 404 clé inconnue, 404 tour désactivé, isolation par profil). Pas de base Postgres locale → exécutés en CI. Refs #42, #44 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { ErrorCode, type SignupInput } 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";
|
|
|
|
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 }),
|
|
};
|
|
}
|
|
|
|
describe("Profile", () => {
|
|
const app = createApp();
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("PATCH /profile/diet", () => {
|
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
const res = await request(app).patch("/profile/diet").send({ dietId: 1 });
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
});
|
|
|
|
it("sets the profile's regime to a valid, seeded diet", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
const diet = await prisma.diet.findFirstOrThrow({
|
|
where: { key: "vegetarian" },
|
|
});
|
|
|
|
const res = await agent.patch("/profile/diet").send({ dietId: diet.id });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.dietId).to.equal(diet.id);
|
|
});
|
|
|
|
it("clears the regime when dietId is null", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
const diet = await prisma.diet.findFirstOrThrow({ where: { key: "vegan" } });
|
|
await agent.patch("/profile/diet").send({ dietId: diet.id });
|
|
|
|
const res = await agent.patch("/profile/diet").send({ dietId: null });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.dietId).to.equal(null);
|
|
});
|
|
|
|
it("rejects an unknown dietId with 404 DIET_NOT_FOUND", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent.patch("/profile/diet").send({ dietId: 999_999 });
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.DIET_NOT_FOUND);
|
|
});
|
|
});
|
|
|
|
describe("GET /profile/allergies + PATCH /profile/allergies", () => {
|
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
const getRes = await request(app).get("/profile/allergies");
|
|
const patchRes = await request(app).patch("/profile/allergies").send({ allergyIds: [] });
|
|
|
|
expect(getRes.status).to.equal(401);
|
|
expect(patchRes.status).to.equal(401);
|
|
});
|
|
|
|
it("starts empty, then reflects a saved selection", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
const allergies = await prisma.allergy.findMany({ include: { category: true } });
|
|
const peanuts = allergies.find((a) => a.category.key === "peanuts");
|
|
const gluten = allergies.find((a) => a.category.key === "gluten");
|
|
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
|
|
|
|
const initial = await agent.get("/profile/allergies");
|
|
expect(initial.body).to.deep.equal([]);
|
|
|
|
const patchRes = await agent
|
|
.patch("/profile/allergies")
|
|
.send({ allergyIds: [peanuts.id, gluten.id] });
|
|
expect(patchRes.status).to.equal(200);
|
|
expect(patchRes.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort());
|
|
|
|
const refetch = await agent.get("/profile/allergies");
|
|
expect(refetch.body.sort()).to.deep.equal([peanuts.id, gluten.id].sort());
|
|
});
|
|
|
|
it("replaces (not merges) the previous selection", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
const allergies = await prisma.allergy.findMany({ include: { category: true } });
|
|
const peanuts = allergies.find((a) => a.category.key === "peanuts");
|
|
const gluten = allergies.find((a) => a.category.key === "gluten");
|
|
if (!peanuts || !gluten) throw new Error("expected seeded allergens missing");
|
|
|
|
await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] });
|
|
await agent.patch("/profile/allergies").send({ allergyIds: [gluten.id] });
|
|
|
|
const res = await agent.get("/profile/allergies");
|
|
expect(res.body).to.deep.equal([gluten.id]);
|
|
});
|
|
|
|
it("rejects an unknown allergyId with 404 ALLERGY_NOT_FOUND", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent.patch("/profile/allergies").send({ allergyIds: [999_999] });
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.ALLERGY_NOT_FOUND);
|
|
});
|
|
});
|
|
|
|
describe("GET /profile/disliked-ingredients + PATCH /profile/disliked-ingredients", () => {
|
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
const getRes = await request(app).get("/profile/disliked-ingredients");
|
|
const patchRes = await request(app)
|
|
.patch("/profile/disliked-ingredients")
|
|
.send({ dislikedIngredientIds: [] });
|
|
|
|
expect(getRes.status).to.equal(401);
|
|
expect(patchRes.status).to.equal(401);
|
|
});
|
|
|
|
it("starts empty, then reflects a saved selection", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
const tomate = await prisma.ingredient.findFirstOrThrow({
|
|
where: { key: "tomato" },
|
|
});
|
|
const oignon = await prisma.ingredient.findFirstOrThrow({
|
|
where: { key: "onion" },
|
|
});
|
|
|
|
const initial = await agent.get("/profile/disliked-ingredients");
|
|
expect(initial.body).to.deep.equal([]);
|
|
|
|
const patchRes = await agent
|
|
.patch("/profile/disliked-ingredients")
|
|
.send({ dislikedIngredientIds: [tomate.id, oignon.id] });
|
|
expect(patchRes.status).to.equal(200);
|
|
expect(patchRes.body.sort()).to.deep.equal([tomate.id, oignon.id].sort());
|
|
|
|
const refetch = await agent.get("/profile/disliked-ingredients");
|
|
expect(refetch.body.sort()).to.deep.equal([tomate.id, oignon.id].sort());
|
|
});
|
|
|
|
it("replaces (not merges) the previous selection", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
const tomate = await prisma.ingredient.findFirstOrThrow({
|
|
where: { key: "tomato" },
|
|
});
|
|
const oignon = await prisma.ingredient.findFirstOrThrow({
|
|
where: { key: "onion" },
|
|
});
|
|
|
|
await agent
|
|
.patch("/profile/disliked-ingredients")
|
|
.send({ dislikedIngredientIds: [tomate.id] });
|
|
await agent
|
|
.patch("/profile/disliked-ingredients")
|
|
.send({ dislikedIngredientIds: [oignon.id] });
|
|
|
|
const res = await agent.get("/profile/disliked-ingredients");
|
|
expect(res.body).to.deep.equal([oignon.id]);
|
|
});
|
|
|
|
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent
|
|
.patch("/profile/disliked-ingredients")
|
|
.send({ dislikedIngredientIds: [999_999] });
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
|
});
|
|
});
|
|
|
|
describe("Guided tours (GET /profile/tours, POST /profile/tours/:key/complete)", () => {
|
|
it("rejects an unauthenticated request with 401 NOT_AUTHENTICATED", async () => {
|
|
const res = await request(app).get("/profile/tours");
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
});
|
|
|
|
it("starts with no completed tours for a fresh profile", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent.get("/profile/tours");
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body).to.deep.equal({ completedTourKeys: [] });
|
|
});
|
|
|
|
it("marks a tour seen and reports it back", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const post = await agent.post("/profile/tours/general/complete").send({});
|
|
expect(post.status).to.equal(204);
|
|
|
|
const res = await agent.get("/profile/tours");
|
|
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
|
|
});
|
|
|
|
it("is idempotent — completing the same tour twice stays a single row", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
await agent.post("/profile/tours/general/complete").send({});
|
|
const second = await agent.post("/profile/tours/general/complete").send({});
|
|
expect(second.status).to.equal(204);
|
|
|
|
const res = await agent.get("/profile/tours");
|
|
expect(res.body.completedTourKeys).to.deep.equal(["general"]);
|
|
});
|
|
|
|
it("accepts { skipped: true } and still marks the tour seen", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const post = await agent
|
|
.post("/profile/tours/techStepMetadata/complete")
|
|
.send({ skipped: true });
|
|
expect(post.status).to.equal(204);
|
|
|
|
const res = await agent.get("/profile/tours");
|
|
expect(res.body.completedTourKeys).to.deep.equal(["techStepMetadata"]);
|
|
});
|
|
|
|
it("rejects an unknown tour key with 404 TOUR_NOT_FOUND", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent.post("/profile/tours/does-not-exist/complete").send({});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
|
|
});
|
|
|
|
it("rejects completing a disabled tour with 404 TOUR_NOT_FOUND", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
await prisma.tourScenario.update({
|
|
where: { key: "techStepMetadata" },
|
|
data: { enabled: false },
|
|
});
|
|
|
|
const res = await agent.post("/profile/tours/techStepMetadata/complete").send({});
|
|
|
|
expect(res.status).to.equal(404);
|
|
expect(res.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
|
|
});
|
|
|
|
it("keeps tours per-profile — one user's completion doesn't leak to another", async () => {
|
|
const alice = request.agent(app);
|
|
await alice.post("/auth/signup").send(buildSignupPayload());
|
|
await alice.post("/profile/tours/general/complete").send({});
|
|
|
|
const bob = request.agent(app);
|
|
await bob.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await bob.get("/profile/tours");
|
|
expect(res.body.completedTourKeys).to.deep.equal([]);
|
|
});
|
|
});
|
|
});
|