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"); }); });