batchCooking/apps/api/test/admin-tours.test.ts
Nicolas 2beff139a3
All checks were successful
CI / lint (push) Successful in 2m57s
CI / build (push) Successful in 2m59s
CI / intent-service-test (push) Successful in 4m22s
CI / e2e (push) Successful in 9m4s
CI / test (push) Successful in 17m32s
feat(api): CRUD admin des scénarios de didacticiel (#44)
Deuxième volet backend du tutoriel guidé, sur le socle de #42/#44 : permet
à un admin de créer/éditer/supprimer les scénarios que
`GET /reference/tours` sert au moteur front.

`/admin/api/tours` (agrégateur `admin.routes.ts`, tout derrière
`requireAdmin`) :
- `GET /` — tous les scénarios (activés **et** désactivés) + étapes
  ordonnées.
- `GET /targets` — le catalogue `TourTarget` pour le sélecteur de cible de
  l'éditeur.
- `POST /` — crée un scénario vide (`key` + `name`) ; 409
  `TOUR_KEY_ALREADY_IN_USE` (nouveau code 4025) sur une clé prise.
- `PATCH /:id` — édite `name`/`enabled` **et remplace toute la liste
  d'étapes en bloc** (delete-all puis recreate avec `order` = index), même
  convention que `updateRecipe` pour les étapes d'une recette. `key`
  immuable. 404 `TOUR_NOT_FOUND` (id inconnu), 404 `TOUR_TARGET_NOT_FOUND`
  (nouveau code 4054, `targetKey` d'une étape hors catalogue — vérifié
  avant d'écrire, les étapes réelles restent intactes si la requête est
  rejetée).
- `DELETE /:id` — 204 ; les étapes et les `TourProgress` des utilisateurs
  cascadent.

Tests Mocha (`admin-tours.test.ts`) : 401 sans session admin sur chaque
route ; list (activés+désactivés) ; targets ; create→patch pour remplir
les étapes ; remplacement en bloc ; 409 clé dupliquée ; 404 cible
inconnue (sans toucher aux étapes existantes) ; 404 id inconnu ; delete +
cascade vérifiée ; un scénario désactivé disparaît de `/reference/tours`
mais reste dans la liste admin.

Refs #44
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 05:39:37 +02:00

215 lines
8.1 KiB
TypeScript

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 { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/**
* `/admin/api/tours/*` — CRUD over the guided-tour scenarios that
* `GET /reference/tours` serves to the web tour engine (issues #42/#44).
* Every route behind `requireAdmin`. The DB starts seeded with the two
* default scenarios (`general`, `techStepMetadata`) and the `TourTarget`
* catalogue (see `reference-seed-data.ts`).
*/
describe("Admin tours — scenario CRUD", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/api/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
expect((await request(app).get("/admin/api/tours")).status).to.equal(401);
expect((await request(app).get("/admin/api/tours/targets")).status).to.equal(401);
expect(
(await request(app).post("/admin/api/tours").send({ key: "x", name: "X" })).status,
).to.equal(401);
expect((await request(app).patch("/admin/api/tours/1").send({})).status).to.equal(401);
expect((await request(app).delete("/admin/api/tours/1")).status).to.equal(401);
});
it("lists the seeded scenarios (enabled and disabled) with their ordered steps", async () => {
const agent = await adminAgent();
await prisma.tourScenario.update({
where: { key: "techStepMetadata" },
data: { enabled: false },
});
const res = await agent.get("/admin/api/tours");
expect(res.status).to.equal(200);
const byKey = (key: string) =>
res.body.find((scenario: { key: string }) => scenario.key === key);
expect(byKey("general").enabled).to.equal(true);
expect(byKey("techStepMetadata").enabled).to.equal(false);
expect(byKey("general").steps.length).to.be.greaterThan(0);
expect(byKey("general").steps[0]).to.have.keys([
"order",
"route",
"targetKey",
"title",
"content",
]);
});
it("lists the highlight-target catalogue", async () => {
const agent = await adminAgent();
const res = await agent.get("/admin/api/tours/targets");
expect(res.status).to.equal(200);
expect(res.body.map((t: { key: string }) => t.key)).to.include("planning-grid");
expect(res.body[0]).to.have.keys(["key", "description"]);
});
it("creates an empty scenario, then fills its steps with a block PATCH", async () => {
const agent = await adminAgent();
const created = await agent
.post("/admin/api/tours")
.send({ key: "welcomeBack", name: "Retour utilisateur" });
expect(created.status).to.equal(201);
expect(created.body).to.include({
key: "welcomeBack",
name: "Retour utilisateur",
enabled: true,
});
expect(created.body.steps).to.deep.equal([]);
const patched = await agent.patch(`/admin/api/tours/${created.body.id}`).send({
name: "Retour utilisateur",
enabled: true,
steps: [
{ route: "/", targetKey: "planning-grid", title: "Le planning", content: "Votre semaine." },
{
route: null,
targetKey: "planning-cook-button",
title: "Cuisiner",
content: "Le bouton pour lancer la séance.",
},
],
});
expect(patched.status).to.equal(200);
expect(patched.body.steps.map((s: { order: number }) => s.order)).to.deep.equal([0, 1]);
expect(patched.body.steps[1].targetKey).to.equal("planning-cook-button");
expect(patched.body.steps[1].route).to.equal(null);
});
it("replaces the whole step list on PATCH (delete-all then recreate)", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "Tour d'orientation",
enabled: true,
steps: [
{ route: null, targetKey: "nav-recipes", title: "Recettes", content: "Une seule étape." },
],
});
expect(res.status).to.equal(200);
expect(res.body.steps).to.have.length(1);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(1);
});
it("rejects a duplicate key with 409 TOUR_KEY_ALREADY_IN_USE", async () => {
const agent = await adminAgent();
const res = await agent.post("/admin/api/tours").send({ key: "general", name: "Doublon" });
expect(res.status).to.equal(409);
expect(res.body.code).to.equal(ErrorCode.TOUR_KEY_ALREADY_IN_USE);
});
it("rejects a PATCH whose step names an unknown target with 404 TOUR_TARGET_NOT_FOUND", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const res = await agent.patch(`/admin/api/tours/${general.id}`).send({
name: "x",
enabled: true,
steps: [{ route: null, targetKey: "not-a-real-target", title: "t", content: "c" }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TOUR_TARGET_NOT_FOUND);
// The scenario's real steps are untouched when the PATCH is rejected.
expect(
await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } }),
).to.be.greaterThan(1);
});
it("404s a PATCH/DELETE against an unknown scenario id", async () => {
const agent = await adminAgent();
const patch = await agent
.patch("/admin/api/tours/999999")
.send({ name: "x", enabled: true, steps: [] });
expect(patch.status).to.equal(404);
expect(patch.body.code).to.equal(ErrorCode.TOUR_NOT_FOUND);
const del = await agent.delete("/admin/api/tours/999999");
expect(del.status).to.equal(404);
});
it("deletes a scenario and cascades its steps and users' progress", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
const user = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
await prisma.tourProgress.create({
data: { userProfileId: user.id, scenarioId: general.id },
});
const res = await agent.delete(`/admin/api/tours/${general.id}`);
expect(res.status).to.equal(204);
expect(await prisma.tourScenario.findUnique({ where: { id: general.id } })).to.equal(null);
expect(await prisma.tourScenarioStep.count({ where: { scenarioId: general.id } })).to.equal(0);
expect(await prisma.tourProgress.count({ where: { scenarioId: general.id } })).to.equal(0);
});
it("hides a disabled scenario from the public GET /reference/tours but keeps it in the admin list", async () => {
const agent = await adminAgent();
const general = await prisma.tourScenario.findUniqueOrThrow({ where: { key: "general" } });
await agent
.patch(`/admin/api/tours/${general.id}`)
.send({ name: "Tour d'orientation", enabled: false, steps: [] });
const publicRes = await request(app).get("/reference/tours");
expect(publicRes.body.map((t: { key: string }) => t.key)).to.not.include("general");
const adminRes = await agent.get("/admin/api/tours");
expect(adminRes.body.map((t: { key: string }) => t.key)).to.include("general");
});
});