From 3363cfad7562c72e534b90d1a58757661b97bacc Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 10:40:23 +0200 Subject: [PATCH] Tests API: couverture Mocha + Cucumber pour le foyer et la suppression de compte (step 4/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - house.test.ts réécrit (le foyer n'est plus auto-créé) + POST /house, POST /house/join, POST /house/leave, DELETE /house/current, DELETE /house/members/:id - auth.test.ts: signup renvoie houseId=null, DELETE /auth/me (mauvais mot de passe, suppression, transfert d'admin) - planning.test.ts/steps.ts: création explicite du foyer (POST /house) - household.feature: scénarios créer/rejoindre/quitter/supprimer/ retirer un membre, via un second agent (CustomWorld.secondAgent) - auth.feature: scénarios de suppression de compte --- apps/api/features/auth.feature | 15 + apps/api/features/household.feature | 57 +++- .../features/step-definitions/auth.steps.ts | 12 + .../step-definitions/household.steps.ts | 80 ++++- .../step-definitions/planning.steps.ts | 10 +- apps/api/features/support/world.ts | 4 + apps/api/test/auth.test.ts | 77 ++++- apps/api/test/house.test.ts | 274 +++++++++++++++++- apps/api/test/planning.test.ts | 10 +- 9 files changed, 516 insertions(+), 23 deletions(-) diff --git a/apps/api/features/auth.feature b/apps/api/features/auth.feature index e08fe1a..c31a7b2 100644 --- a/apps/api/features/auth.feature +++ b/apps/api/features/auth.feature @@ -33,3 +33,18 @@ Feature: Account creation and login When I log in with email "alice@example.com" and password "wrong-password" Then the response status should be 401 And the response error code should be "INVALID_CREDENTIALS" + + Scenario: A signed-in user cannot delete their account with the wrong password + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I delete my account with password "wrong-password" + Then the response status should be 401 + And the response error code should be "INVALID_CREDENTIALS" + And I am authenticated as "alice@example.com" + + Scenario: A signed-in user deletes their account + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I delete my account with password "correct-horse-battery-staple" + Then the response status should be 204 + And I am no longer authenticated diff --git a/apps/api/features/household.feature b/apps/api/features/household.feature index 94a56e2..bb1e874 100644 --- a/apps/api/features/household.feature +++ b/apps/api/features/household.feature @@ -1,16 +1,67 @@ -Feature: Household name +Feature: Household As a signed-in user - I want to name my household - So that it's recognizable as ours, not the auto-generated default + I want to name my household, invite others to it, and manage its members + So that my whole household can share the same planning Scenario: A visitor without a session cannot read the household When I send a GET request to "/house/current" Then the response status should be 401 And the response error code should be "NOT_AUTHENTICATED" + Scenario: A signed-in user creates a household + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I create a household named "Chez Alice" + Then the response status should be 201 + And my household should be named "Chez Alice" + Scenario: A signed-in user renames their household Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Foyer de test" When I rename my household to "Chez les Martin" Then the response status should be 200 And my household should be named "Chez les Martin" + + Scenario: A second user joins a household using its invite code + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + When the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + Then the second user's response status should be 200 + And the second user should be a member of my household + + Scenario: A non-admin member cannot delete the household + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + When the second user tries to delete the household + Then the second user's response status should be 403 + And the second user's response error code should be "NOT_HOUSE_ADMIN" + + Scenario: Adminship transfers to the remaining member when the admin leaves + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + When I leave the household + Then the response status should be 204 + And the second user should be the household's admin + + Scenario: The admin removes a member + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And I have a household named "Chez Alice" + And a profile already exists with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user logs in with email "bob@example.com" and password "correct-horse-battery-staple" + And the second user joins my household using its invite code + When I remove the second user from my household + Then the response status should be 200 + And the second user should have no household diff --git a/apps/api/features/step-definitions/auth.steps.ts b/apps/api/features/step-definitions/auth.steps.ts index 64149e5..9f5a737 100644 --- a/apps/api/features/step-definitions/auth.steps.ts +++ b/apps/api/features/step-definitions/auth.steps.ts @@ -48,8 +48,20 @@ When( }, ); +When( + "I delete my account with password {string}", + async function (this: CustomWorld, password: string) { + this.response = await this.agent.delete("/auth/me").send({ password }); + }, +); + Then("I am authenticated as {string}", async function (this: CustomWorld, email: string) { const res = await this.agent.get("/auth/me"); assert.equal(res.status, 200); assert.equal(res.body.email, email); }); + +Then("I am no longer authenticated", async function (this: CustomWorld) { + const res = await this.agent.get("/auth/me"); + assert.equal(res.status, 401); +}); diff --git a/apps/api/features/step-definitions/household.steps.ts b/apps/api/features/step-definitions/household.steps.ts index 4a8dabb..9e48e0d 100644 --- a/apps/api/features/step-definitions/household.steps.ts +++ b/apps/api/features/step-definitions/household.steps.ts @@ -1,12 +1,90 @@ import assert from "node:assert/strict"; -import { Then, When } from "@cucumber/cucumber"; +import { ErrorCode } from "@batch-cooking/shared"; +import { Given, Then, When } from "@cucumber/cucumber"; import type { CustomWorld } from "../support/world.js"; +Given("I have a household named {string}", async function (this: CustomWorld, name: string) { + const res = await this.agent.post("/house").send({ name }); + assert.equal(res.status, 201, JSON.stringify(res.body)); +}); + +When("I create a household named {string}", async function (this: CustomWorld, name: string) { + this.response = await this.agent.post("/house").send({ name }); +}); + When("I rename my household to {string}", async function (this: CustomWorld, name: string) { this.response = await this.agent.patch("/house/current").send({ name }); }); +When("I leave the household", async function (this: CustomWorld) { + this.response = await this.agent.post("/house/leave"); +}); + +When("I remove the second user from my household", async function (this: CustomWorld) { + const secondMe = await this.secondAgent.get("/auth/me"); + this.response = await this.agent.delete(`/house/members/${secondMe.body.id}`); +}); + Then("my household should be named {string}", async function (this: CustomWorld, name: string) { const res = await this.agent.get("/house/current"); assert.equal(res.body.name, name); }); + +// --- Steps involving a second, independently signed-in user --------------- +// The first ("a profile already exists with email ...") step is reused +// as-is for the second user too — it just inserts a row, agent-agnostic. + +When( + "the second user logs in with email {string} and password {string}", + async function (this: CustomWorld, email: string, password: string) { + this.secondResponse = await this.secondAgent.post("/auth/login").send({ email, password }); + }, +); + +When( + "the second user joins my household using its invite code", + async function (this: CustomWorld) { + const house = await this.agent.get("/house/current"); + this.secondResponse = await this.secondAgent + .post("/house/join") + .send({ inviteCode: house.body.inviteCode }); + }, +); + +When("the second user tries to delete the household", async function (this: CustomWorld) { + this.secondResponse = await this.secondAgent.delete("/house/current"); +}); + +Then( + "the second user's response status should be {int}", + function (this: CustomWorld, status: number) { + assert.equal(this.secondResponse.status, status); + }, +); + +Then( + "the second user's response error code should be {string}", + function (this: CustomWorld, code: string) { + const expected = ErrorCode[code as keyof typeof ErrorCode]; + assert.notEqual(expected, undefined, `Unknown ErrorCode member: "${code}"`); + assert.equal(this.secondResponse.body.code, expected); + }, +); + +Then("the second user should be a member of my household", async function (this: CustomWorld) { + const house = await this.agent.get("/house/current"); + const secondMe = await this.secondAgent.get("/auth/me"); + const memberIds = (house.body.members as Array<{ id: number }>).map((member) => member.id); + assert.ok(memberIds.includes(secondMe.body.id)); +}); + +Then("the second user should be the household's admin", async function (this: CustomWorld) { + const secondMe = await this.secondAgent.get("/auth/me"); + const house = await this.secondAgent.get("/house/current"); + assert.equal(house.body.adminId, secondMe.body.id); +}); + +Then("the second user should have no household", async function (this: CustomWorld) { + const secondMe = await this.secondAgent.get("/auth/me"); + assert.equal(secondMe.body.houseId, null); +}); diff --git a/apps/api/features/step-definitions/planning.steps.ts b/apps/api/features/step-definitions/planning.steps.ts index 67a25b1..12c36eb 100644 --- a/apps/api/features/step-definitions/planning.steps.ts +++ b/apps/api/features/step-definitions/planning.steps.ts @@ -15,14 +15,14 @@ Then("the current planning response should be empty", function (this: CustomWorl // the API — there's no "create a planning" endpoint yet (see // specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still // TODO), so this is the only way to get a household into a state where it -// has one. Reads the household off the already-authenticated agent (via -// `GET /auth/me`) rather than taking it as a step argument, since the -// scenario never names it explicitly. +// has one. A household is no longer created implicitly at signup, so this +// step creates one via `POST /house` first — the scenario never names it +// explicitly, its name doesn't matter here. Given( "my household has a planning covering today with recipe {string} on {string} for {string}", async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) { - const me = await this.agent.get("/auth/me"); - const houseId: number = me.body.houseId; + const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" }); + const houseId: number = houseRes.body.id; const recipe = await prisma.recipe.create({ data: { name: recipeName } }); const today = new Date(); diff --git a/apps/api/features/support/world.ts b/apps/api/features/support/world.ts index d8460b5..69b84e6 100644 --- a/apps/api/features/support/world.ts +++ b/apps/api/features/support/world.ts @@ -11,11 +11,15 @@ export class CustomWorld extends World { app: Express; agent: ReturnType; response!: request.Response; + /** A second, independent session (own cookie jar) — only used by scenarios needing two distinct signed-in users, e.g. household invites/admin transfer/member removal. */ + secondAgent: ReturnType; + secondResponse!: request.Response; constructor(options: IWorldOptions) { super(options); this.app = createApp(); this.agent = request.agent(this.app); + this.secondAgent = request.agent(this.app); } } diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts index c071e25..8383a6b 100644 --- a/apps/api/test/auth.test.ts +++ b/apps/api/test/auth.test.ts @@ -38,7 +38,7 @@ describe("Auth", () => { }); describe("POST /auth/signup", () => { - it("creates a profile and its house, and sets a session cookie", async () => { + it("creates a profile without a household yet, and sets a session cookie", async () => { const payload = buildSignupPayload(); const res = await request(app).post("/auth/signup").send(payload); @@ -49,7 +49,9 @@ describe("Auth", () => { email: payload.email, }); expect(res.body).to.not.have.property("passwordHash"); - expect(res.body.houseId).to.be.a("number"); + // No household is created at signup anymore — it's an optional + // onboarding step (create/join/skip), see house.test.ts. + expect(res.body.houseId).to.equal(null); expect(res.headers["set-cookie"]?.[0]).to.include("session="); }); @@ -131,4 +133,75 @@ describe("Auth", () => { expect(res.body.email).to.equal(payload.email); }); }); + + describe("DELETE /auth/me", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).delete("/auth/me").send({ password: "whatever" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects a wrong password with 401 INVALID_CREDENTIALS, without deleting the profile", async () => { + const payload = buildSignupPayload(); + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(payload); + + const res = await agent.delete("/auth/me").send({ password: "wrong-password" }); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); + expect( + await prisma.userProfile.findUnique({ where: { id: signupRes.body.id } }), + ).to.not.equal(null); + }); + + it("deletes the profile and clears the session cookie", async () => { + const payload = buildSignupPayload(); + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(payload); + + const res = await agent.delete("/auth/me").send({ password: payload.password }); + + expect(res.status).to.equal(204); + expect(await prisma.userProfile.findUnique({ where: { id: signupRes.body.id } })).to.equal( + null, + ); + + const meRes = await agent.get("/auth/me"); + expect(meRes.status).to.equal(401); + }); + + it("deletes the household along with the account when it's the sole member", async () => { + const payload = buildSignupPayload(); + const agent = request.agent(app); + await agent.post("/auth/signup").send(payload); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + + const res = await agent.delete("/auth/me").send({ password: payload.password }); + + expect(res.status).to.equal(204); + expect(await prisma.house.findUnique({ where: { id: houseRes.body.id } })).to.equal(null); + }); + + it("transfers adminship to another member before deleting an admin's account", async () => { + const adminPayload = buildSignupPayload(); + const adminAgent = request.agent(app); + const houseRes = await adminAgent + .post("/auth/signup") + .send(adminPayload) + .then(() => adminAgent.post("/house").send({ name: "Chez nous" })); + + const memberPayload = buildSignupPayload(); + const memberAgent = request.agent(app); + const memberSignupRes = await memberAgent.post("/auth/signup").send(memberPayload); + await memberAgent.post("/house/join").send({ inviteCode: houseRes.body.inviteCode }); + + const res = await adminAgent.delete("/auth/me").send({ password: adminPayload.password }); + + expect(res.status).to.equal(204); + const house = await prisma.house.findUnique({ where: { id: houseRes.body.id } }); + expect(house?.adminId).to.equal(memberSignupRes.body.id); + }); + }); }); diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts index 87cd0cf..29e6206 100644 --- a/apps/api/test/house.test.ts +++ b/apps/api/test/house.test.ts @@ -1,6 +1,7 @@ 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"; @@ -17,6 +18,13 @@ function buildSignupPayload(): SignupInput { }; } +/** 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(); @@ -36,14 +44,28 @@ describe("Household", () => { expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); - it("returns the household created at signup", async () => { - const agent = request.agent(app); - const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + 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.deep.equal({ id: signupRes.body.houseId, name: res.body.name }); + 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 }, + ]); }); }); @@ -55,9 +77,18 @@ describe("Household", () => { 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 = request.agent(app); - await agent.post("/auth/signup").send(buildSignupPayload()); + 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" }); @@ -69,8 +100,8 @@ describe("Household", () => { }); it("rejects an empty name with 400 VALIDATION_ERROR", async () => { - const agent = request.agent(app); - await agent.post("/auth/signup").send(buildSignupPayload()); + const { agent } = await signupAgent(app); + await agent.post("/house").send({ name: "Chez Alice" }); const res = await agent.patch("/house/current").send({ name: "" }); @@ -78,4 +109,231 @@ describe("Household", () => { 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); + }); + }); }); diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index f83395e..71fffe0 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -49,8 +49,9 @@ describe("Planning", () => { it("returns the household's planning covering today, with recipes resolved", async () => { const agent = request.agent(app); - const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); - const houseId: number = signupRes.body.houseId; + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const houseId: number = houseRes.body.id; const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } }); const today = new Date(); @@ -80,8 +81,9 @@ describe("Planning", () => { it("returns null when the household's planning does not cover today", async () => { const agent = request.agent(app); - const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); - const houseId: number = signupRes.body.houseId; + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + const houseId: number = houseRes.body.id; // A planning entirely in the past — shouldn't be picked up as "current". await prisma.planning.create({