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>
215 lines
8.1 KiB
TypeScript
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");
|
|
});
|
|
});
|