import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; import { expect } from "chai"; import type { Express } from "express"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js"; import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.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 }), }; } /** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { return { key, name, official: false, iconUrl: null, async list() { return { items: [], nextCursor: null }; }, async fetchDetail() { throw new Error("not implemented"); }, parse() { throw new Error("not implemented"); }, }; } /** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */ async function signupAgent(app: Express) { const agent = request.agent(app); const res = await agent.post("/auth/signup").send(buildSignupPayload()); return { agent, profile: res.body }; } describe("Household", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); describe("GET /house/current", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).get("/house/current"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("returns null when the profile has no household yet", async () => { const { agent } = await signupAgent(app); const res = await agent.get("/house/current"); expect(res.status).to.equal(200); expect(res.body).to.equal(null); }); it("returns the household with its admin and member list, once created", async () => { const { agent, profile } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.get("/house/current"); expect(res.status).to.equal(200); expect(res.body.name).to.equal("Chez Alice"); expect(res.body.adminId).to.equal(profile.id); expect(res.body.inviteCode).to.match(/^[A-Z2-9]{8}$/); expect(res.body.members).to.deep.equal([ { id: profile.id, firstName: profile.firstName, lastName: profile.lastName }, ]); }); }); describe("PATCH /house/current", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).patch("/house/current").send({ name: "Chez nous" }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects renaming when the profile has no household yet with 404 HOUSE_NOT_FOUND", async () => { const { agent } = await signupAgent(app); const res = await agent.patch("/house/current").send({ name: "Chez nous" }); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); }); it("renames the household", async () => { const { agent } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" }); expect(res.status).to.equal(200); expect(res.body.name).to.equal("Chez les Dupont"); const refetch = await agent.get("/house/current"); expect(refetch.body.name).to.equal("Chez les Dupont"); }); it("rejects an empty name with 400 VALIDATION_ERROR", async () => { const { agent } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.patch("/house/current").send({ name: "" }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); }); describe("POST /house", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).post("/house").send({ name: "Chez nous" }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("creates a household with the caller as its admin", async () => { const { agent, profile } = await signupAgent(app); const res = await agent.post("/house").send({ name: "Chez Alice" }); expect(res.status).to.equal(201); expect(res.body.name).to.equal("Chez Alice"); expect(res.body.adminId).to.equal(profile.id); const me = await agent.get("/auth/me"); expect(me.body.houseId).to.equal(res.body.id); }); it("rejects an empty name with 400 VALIDATION_ERROR", async () => { const { agent } = await signupAgent(app); const res = await agent.post("/house").send({ name: "" }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("rejects creating a second household with 409 ALREADY_HAS_HOUSE", async () => { const { agent } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.post("/house").send({ name: "Chez Alice bis" }); expect(res.status).to.equal(409); expect(res.body.code).to.equal(ErrorCode.ALREADY_HAS_HOUSE); }); }); describe("POST /house/join", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).post("/house/join").send({ inviteCode: "ABCDEFGH" }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("joins an existing household by invite code", async () => { const { agent: adminAgent } = await signupAgent(app); const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); const { agent: joinerAgent, profile: joiner } = await signupAgent(app); const res = await joinerAgent .post("/house/join") .send({ inviteCode: created.body.inviteCode }); expect(res.status).to.equal(200); expect(res.body.id).to.equal(created.body.id); expect(res.body.members.map((m: { id: number }) => m.id)).to.include(joiner.id); }); it("rejects an unknown invite code with 404 INVITE_CODE_NOT_FOUND", async () => { const { agent } = await signupAgent(app); const res = await agent.post("/house/join").send({ inviteCode: "ZZZZZZZZ" }); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.INVITE_CODE_NOT_FOUND); }); it("rejects joining when the profile already belongs to a household with 409 ALREADY_HAS_HOUSE", async () => { const { agent: adminAgent } = await signupAgent(app); const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); const { agent } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Bob" }); const res = await agent.post("/house/join").send({ inviteCode: created.body.inviteCode }); expect(res.status).to.equal(409); expect(res.body.code).to.equal(ErrorCode.ALREADY_HAS_HOUSE); }); }); describe("POST /house/leave", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).post("/house/leave"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects leaving when the profile has no household with 404 HOUSE_NOT_FOUND", async () => { const { agent } = await signupAgent(app); const res = await agent.post("/house/leave"); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); }); it("deletes the household when its sole member leaves", async () => { const { agent } = await signupAgent(app); const created = await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.post("/house/leave"); expect(res.status).to.equal(204); expect(await prisma.house.findUnique({ where: { id: created.body.id } })).to.equal(null); }); it("transfers adminship to the remaining member when the admin leaves", async () => { const { agent: adminAgent } = await signupAgent(app); const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); const { agent: memberAgent, profile: member } = await signupAgent(app); await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); const res = await adminAgent.post("/house/leave"); expect(res.status).to.equal(204); const house = await prisma.house.findUnique({ where: { id: created.body.id } }); expect(house?.adminId).to.equal(member.id); }); }); describe("DELETE /house/current", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).delete("/house/current"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects deleting when the profile has no household with 404 HOUSE_NOT_FOUND", async () => { const { agent } = await signupAgent(app); const res = await agent.delete("/house/current"); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); }); it("rejects a non-admin member with 403 NOT_HOUSE_ADMIN", async () => { const { agent: adminAgent } = await signupAgent(app); const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); const { agent: memberAgent } = await signupAgent(app); await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); const res = await memberAgent.delete("/house/current"); expect(res.status).to.equal(403); expect(res.body.code).to.equal(ErrorCode.NOT_HOUSE_ADMIN); }); it("deletes the household for every member, cascading its plannings", async () => { const { agent: adminAgent } = await signupAgent(app); const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); const { agent: memberAgent, profile: member } = await signupAgent(app); await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); const planning = await prisma.planning.create({ data: { houseId: created.body.id, startDate: new Date(Date.UTC(2000, 0, 1)), finishDate: new Date(Date.UTC(2000, 0, 7)), }, }); const res = await adminAgent.delete("/house/current"); expect(res.status).to.equal(204); expect(await prisma.house.findUnique({ where: { id: created.body.id } })).to.equal(null); expect(await prisma.planning.findUnique({ where: { id: planning.id } })).to.equal(null); const memberProfile = await prisma.userProfile.findUniqueOrThrow({ where: { id: member.id }, }); expect(memberProfile.houseId).to.equal(null); }); }); describe("DELETE /house/members/:memberId", () => { it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const res = await request(app).delete("/house/members/1"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); it("rejects a non-admin member with 403 NOT_HOUSE_ADMIN", async () => { const { agent: adminAgent, profile: admin } = await signupAgent(app); const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); const { agent: memberAgent } = await signupAgent(app); await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); const res = await memberAgent.delete(`/house/members/${admin.id}`); expect(res.status).to.equal(403); expect(res.body.code).to.equal(ErrorCode.NOT_HOUSE_ADMIN); }); it("rejects the admin trying to remove themselves with 400 VALIDATION_ERROR", async () => { const { agent: adminAgent, profile: admin } = await signupAgent(app); await adminAgent.post("/house").send({ name: "Chez Alice" }); const res = await adminAgent.delete(`/house/members/${admin.id}`); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); it("removes the targeted member from the household", async () => { const { agent: adminAgent } = await signupAgent(app); const created = await adminAgent.post("/house").send({ name: "Chez Alice" }); const { agent: memberAgent, profile: member } = await signupAgent(app); await memberAgent.post("/house/join").send({ inviteCode: created.body.inviteCode }); const res = await adminAgent.delete(`/house/members/${member.id}`); expect(res.status).to.equal(200); expect(res.body.members.map((m: { id: number }) => m.id)).to.not.include(member.id); const memberProfile = await prisma.userProfile.findUniqueOrThrow({ where: { id: member.id }, }); expect(memberProfile.houseId).to.equal(null); }); }); describe("GET /house/current/sources + PATCH /house/current/sources", () => { beforeEach(() => { clearRecipeSources(); }); afterEach(() => { clearRecipeSources(); }); it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { const getRes = await request(app).get("/house/current/sources"); const patchRes = await request(app).patch("/house/current/sources").send({ sourceIds: [] }); expect(getRes.status).to.equal(401); expect(patchRes.status).to.equal(401); }); it("rejects reading/writing with 404 HOUSE_NOT_FOUND when the profile has no household yet", async () => { const { agent } = await signupAgent(app); const getRes = await agent.get("/house/current/sources"); const patchRes = await agent.patch("/house/current/sources").send({ sourceIds: [] }); expect(getRes.status).to.equal(404); expect(getRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); expect(patchRes.status).to.equal(404); expect(patchRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); }); it("starts with nothing enabled, opt-in — not just an empty array by coincidence", async () => { const { agent } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.get("/house/current/sources"); expect(res.status).to.equal(200); expect(res.body).to.deep.equal([]); }); it("replaces (not merges) the enabled-source set, and it's readable back", async () => { registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); registerRecipeSource(buildFakeAdapter("otherSource", "Other Source")); await syncRecipeSources(prisma); const fakeSource = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } }); const { agent } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Alice" }); const firstPatch = await agent .patch("/house/current/sources") .send({ sourceIds: [fakeSource.id, otherSource.id] }); expect(firstPatch.status).to.equal(200); expect(firstPatch.body.sort()).to.deep.equal([fakeSource.id, otherSource.id].sort()); const secondPatch = await agent .patch("/house/current/sources") .send({ sourceIds: [fakeSource.id] }); expect(secondPatch.status).to.equal(200); expect(secondPatch.body).to.deep.equal([fakeSource.id]); const res = await agent.get("/house/current/sources"); expect(res.body).to.deep.equal([fakeSource.id]); }); it("rejects an unknown sourceId with 404 SOURCE_NOT_FOUND", async () => { const { agent } = await signupAgent(app); await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.patch("/house/current/sources").send({ sourceIds: [999999] }); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); }); }); });