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([]); }); }); });