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/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql b/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql new file mode 100644 index 0000000..e355307 --- /dev/null +++ b/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "house" ADD COLUMN "admin_id" INTEGER NOT NULL, +ADD COLUMN "invite_code" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "house_invite_code_key" ON "house"("invite_code"); + +-- AddForeignKey +ALTER TABLE "house" ADD CONSTRAINT "house_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "user_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ebe8716..769943d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -13,10 +13,19 @@ datasource db { // ----------------------------------------------------------------------------- model House { - id Int @id @default(autoincrement()) - name String + id Int @id @default(autoincrement()) + name String + /// The member who administers this household — created it, or inherited + /// adminship when the previous admin left/deleted their account (see + /// `house.service.ts`'s `leaveCurrentHouse`). Always set: a house is + /// deleted outright once it would otherwise have no admin left. + adminId Int @map("admin_id") + /// Shareable code another user enters via `POST /house/join` to become a + /// member — see `house.service.ts`'s generator for the charset/length. + inviteCode String @unique @map("invite_code") - members UserProfile[] + admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id]) + members UserProfile[] @relation("HouseMember") plannings Planning[] @@map("house") @@ -81,9 +90,14 @@ model UserProfile { houseId Int? @map("house_id") dietId Int? @map("diet_id") - house House? @relation(fields: [houseId], references: [id], onDelete: SetNull) - diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) - allergies UserProfileAllergy[] + house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) + diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) + allergies UserProfileAllergy[] + /// Households this profile administers. In practice at most one — a + /// profile can only ever belong to (and thus admin) a single household at + /// a time — but Prisma models the admin side of a one-to-many FK as a + /// list regardless of that real-world cardinality. + administeredHouses House[] @relation("HouseAdmin") @@map("user_profiles") } diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index 044a8ab..5a97e2e 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -1,10 +1,10 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { loginSchema, signupSchema } from "@batch-cooking/shared"; +import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared"; import { Router } from "express"; import type { CookieOptions, Response } from "express"; import { env } from "../../config/env.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { login, signup } from "./auth.service.js"; +import { deleteAccount, login, signup } from "./auth.service.js"; /** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */ export const authRouter = Router(); @@ -15,7 +15,7 @@ export const authRouter = Router(); // bounds how long the browser keeps *sending* the cookie at all. const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; -/** Cookie options shared by every route that sets/clears the session cookie. */ +/** Cookie options shared by every route that sets the session cookie. */ const cookieOptions: CookieOptions = { httpOnly: true, // Only require HTTPS in production — local dev/CI serve over plain HTTP. @@ -24,6 +24,12 @@ const cookieOptions: CookieOptions = { maxAge: SEVEN_DAYS_MS, }; +// `res.clearCookie` sets its own expiry to clear the cookie — passing +// `maxAge` alongside is deprecated (and pointless) as of Express 4.20, so +// every clearing route reuses `cookieOptions` minus that one field rather +// than duplicating the rest by hand. +const { maxAge: _maxAge, ...clearCookieOptions } = cookieOptions; + /** * Creates a profile (+ its household) and logs the new user in * immediately. `wrapAsyncHandler` forwards a thrown/rejected error to @@ -52,7 +58,7 @@ authRouter.post( /** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */ authRouter.post("/logout", (_req, res) => { - res.clearCookie(env.AUTH_COOKIE_NAME, cookieOptions); + res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions); res.status(204).end(); }); @@ -60,3 +66,15 @@ authRouter.post("/logout", (_req, res) => { authRouter.get("/me", requireAuth, (_req, res: Response) => { res.status(200).json(res.locals.userProfile); }); + +/** Permanently deletes the current profile (re-verifying its password first) and clears the session cookie. */ +authRouter.delete( + "/me", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = deleteAccountSchema.parse(req.body); + await deleteAccount(res.locals.userProfile.id, input.password); + res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions); + res.status(204).end(); + }), +); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 24a6508..e0bc542 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -10,6 +10,7 @@ import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; import { signAuthToken } from "../../lib/jwt.js"; import { toSafeProfile } from "../../lib/safe-profile.js"; +import { leaveCurrentHouse } from "../house/house.service.js"; /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ interface AuthResult { @@ -28,8 +29,8 @@ const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; /** - * Creates a new household (`house`) and profile (`user_profiles`) together - * in one transaction, hashes the password, and issues a session token. + * Creates a profile (`user_profiles`), hashes the password, and issues a + * session token. * * @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken. */ @@ -41,29 +42,49 @@ export async function signup(input: SignupInput): Promise { const passwordHash = await argon2.hash(input.password, hashOptions); - // A profile always belongs to a house; signup creates one, named after - // the new user for now — renamed via `PATCH /house/current` (the - // household step of the profile journey). Joining an existing house is a - // separate, not-yet-built feature. - const profile = await prisma.$transaction(async (tx) => { - const house = await tx.house.create({ - data: { name: `Foyer de ${input.firstName}` }, - }); - return tx.userProfile.create({ - data: { - firstName: input.firstName, - lastName: input.lastName, - email: input.email, - passwordHash, - houseId: house.id, - }, - }); + // No household is created here — it's now an optional step of the + // onboarding wizard (create or join one, or skip — see + // `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side + // effect of signing up. `houseId` starts out `null`, same as `dietId`. + const profile = await prisma.userProfile.create({ + data: { + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + passwordHash, + }, }); const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion }); return { profile: toSafeProfile(profile), token }; } +/** + * Permanently deletes a profile, after re-verifying its password — + * deleting an account is irreversible, so it's gated behind proving the + * caller still is who the session says they are, same spirit as the + * password check in {@link login}. + * + * If the profile administers a household with other members, adminship is + * handed off before the profile is deleted (see `house.service.ts`'s + * `leaveCurrentHouse`); if it's the household's last member, the household + * itself is deleted along with it. `UserProfileAllergy` rows cascade via + * the schema's `onDelete: Cascade`. + * + * @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong. + */ +export async function deleteAccount(profileId: number, password: string): Promise { + const profile = await prisma.userProfile.findUnique({ where: { id: profileId } }); + if (!profile || !(await argon2.verify(profile.passwordHash, password))) { + throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password"); + } + + if (profile.houseId !== null) { + await leaveCurrentHouse(profile.id, profile.houseId); + } + await prisma.userProfile.delete({ where: { id: profile.id } }); +} + /** * Verifies credentials and issues a fresh session token. * diff --git a/apps/api/src/modules/house/house.routes.ts b/apps/api/src/modules/house/house.routes.ts index ec9c67d..ec9fde9 100644 --- a/apps/api/src/modules/house/house.routes.ts +++ b/apps/api/src/modules/house/house.routes.ts @@ -1,10 +1,24 @@ +import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { renameHouseSchema } from "@batch-cooking/shared"; +import { + ErrorCode, + createHouseSchema, + joinHouseSchema, + renameHouseSchema, +} from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { getCurrentHouse, renameHouse } from "./house.service.js"; +import { + createHouse, + deleteHouse, + getCurrentHouse, + joinHouse, + leaveCurrentHouse, + removeMember, + renameHouse, +} from "./house.service.js"; -/** Router mounted at `/house` in app.ts. Both routes require a session — a household is per-user (via their profile), never public. */ +/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */ export const houseRouter = Router(); houseRouter.get( @@ -16,7 +30,7 @@ houseRouter.get( }), ); -/** The household step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ +/** The household step of the profile journey (onboarding wizard and the `/parametres/foyer` settings page both call this) — renaming, open to any member. */ houseRouter.patch( "/current", requireAuth, @@ -26,3 +40,71 @@ houseRouter.patch( res.status(200).json(house); }), ); + +/** Creates a new household for a profile that doesn't have one yet — the "create" half of the optional household step. */ +houseRouter.post( + "/", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = createHouseSchema.parse(req.body); + const house = await createHouse( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + input.name, + ); + res.status(201).json(house); + }), +); + +/** Joins an existing household by invite code — the "join" half of the optional household step. */ +houseRouter.post( + "/join", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = joinHouseSchema.parse(req.body); + const house = await joinHouse( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + input.inviteCode, + ); + res.status(200).json(house); + }), +); + +/** Removes the caller from their own household — see `deleteHouse` below for removing the household itself. */ +houseRouter.post( + "/leave", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + await leaveCurrentHouse(res.locals.userProfile.id, res.locals.userProfile.houseId); + res.status(204).end(); + }), +); + +/** Deletes the household entirely — every member loses it. Admin-only, see `house.service.ts`. */ +houseRouter.delete( + "/current", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + await deleteHouse(res.locals.userProfile.id, res.locals.userProfile.houseId); + res.status(204).end(); + }), +); + +/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */ +houseRouter.delete( + "/members/:memberId", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const memberId = Number(req.params.memberId); + if (!Number.isInteger(memberId)) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "memberId must be an integer"); + } + const house = await removeMember( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + memberId, + ); + res.status(200).json(house); + }), +); diff --git a/apps/api/src/modules/house/house.service.ts b/apps/api/src/modules/house/house.service.ts index 6a34438..05c30a2 100644 --- a/apps/api/src/modules/house/house.service.ts +++ b/apps/api/src/modules/house/house.service.ts @@ -1,17 +1,59 @@ +import { randomInt } from "node:crypto"; import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode, type HouseView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; -/** Returns the profile's household, or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ +/** + * Charset for {@link generateInviteCode} — uppercase letters/digits only, + * minus the visually-ambiguous `0`/`O`/`1`/`I` (this code is meant to be + * read off one screen and typed into another). + */ +const INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +const INVITE_CODE_LENGTH = 8; + +/** Generates one candidate invite code. Collisions are handled by the caller (retry on the DB's unique-constraint failure), not here. */ +function generateInviteCode(): string { + let code = ""; + for (let i = 0; i < INVITE_CODE_LENGTH; i++) { + code += INVITE_CODE_CHARS[randomInt(INVITE_CODE_CHARS.length)]; + } + return code; +} + +/** Shapes a Prisma `House` (with its `members` relation included) into the public {@link HouseView}. */ +function toHouseView(house: { + id: number; + name: string; + adminId: number; + inviteCode: string; + members: { id: number; firstName: string; lastName: string }[]; +}): HouseView { + return { + id: house.id, + name: house.name, + adminId: house.adminId, + inviteCode: house.inviteCode, + members: house.members, + }; +} + +/** Shared `include` for every query that needs to return a full {@link HouseView}. */ +const houseWithMembers = { + members: { select: { id: true, firstName: true, lastName: true } }, +} as const; + +/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ export async function getCurrentHouse(houseId: number | null): Promise { if (houseId === null) { return null; } - return findHouseOrThrow(houseId); + return toHouseView(await findHouseOrThrow(houseId)); } /** - * Renames the profile's household. + * Renames the profile's household. Open to any member, not just the admin — + * unlike deleting the household or removing a member, renaming isn't + * destructive enough to gate behind adminship. * * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. */ @@ -20,7 +62,198 @@ export async function renameHouse(houseId: number | null, name: string): Promise throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); } await findHouseOrThrow(houseId); - return prisma.house.update({ where: { id: houseId }, data: { name } }); + const house = await prisma.house.update({ + where: { id: houseId }, + data: { name }, + include: houseWithMembers, + }); + return toHouseView(house); +} + +/** + * Creates a new household for a profile that doesn't have one yet, with the + * creating profile as its admin. + * + * @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household. + */ +export async function createHouse( + profileId: number, + houseId: number | null, + name: string, +): Promise { + if (houseId !== null) { + throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household"); + } + + // Astronomically unlikely to collide (33^8 possibilities), but retried + // rather than assumed — a `@unique` constraint failure is the only fully + // reliable way to detect it. + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const house = await prisma.$transaction(async (tx) => { + const created = await tx.house.create({ + data: { name, adminId: profileId, inviteCode: generateInviteCode() }, + }); + await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } }); + return created; + }); + return getCurrentHouseOrThrow(house.id); + } catch (err) { + if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue; + throw err; + } + } + throw new Error("Failed to generate a unique invite code after several attempts"); +} + +/** + * Joins an existing household by invite code. + * + * @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household. + * @throws {HttpError} `404 INVITE_CODE_NOT_FOUND` if no household matches the code. + */ +export async function joinHouse( + profileId: number, + houseId: number | null, + inviteCode: string, +): Promise { + if (houseId !== null) { + throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household"); + } + + const house = await prisma.house.findUnique({ where: { inviteCode } }); + if (!house) { + throw new HttpError( + 404, + ErrorCode.INVITE_CODE_NOT_FOUND, + "No household matches this invite code", + ); + } + + await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } }); + return getCurrentHouseOrThrow(house.id); +} + +/** + * Removes a profile from its current household — used both by + * `POST /house/leave` (a member removing themselves) and by account + * deletion (`auth.service.ts`'s `deleteAccount`, before the profile row + * itself is deleted). + * + * If the leaving profile was the household's admin: adminship transfers to + * the longest-standing remaining member (lowest id) if there is one, + * otherwise the household itself is deleted (cascading its plannings) since + * a household can never be left without an admin. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. + */ +export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + + const house = await findHouseOrThrow(houseId); + const remainingMembers = house.members.filter((member) => member.id !== profileId); + + await prisma.$transaction(async (tx) => { + await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } }); + + if (house.adminId !== profileId) { + return; + } + if (remainingMembers.length === 0) { + await tx.house.delete({ where: { id: house.id } }); + return; + } + const nextAdmin = remainingMembers.reduce((oldest, member) => + member.id < oldest.id ? member : oldest, + ); + await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } }); + }); +} + +/** + * Deletes a household outright — every member (not just the caller) loses + * it, and its plannings are cascaded away. Only the household's admin may + * do this; a non-admin member wanting out should call + * {@link leaveCurrentHouse} (`POST /house/leave`) instead. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. + * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin. + */ +export async function deleteHouse(profileId: number, houseId: number | null): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + const house = await findHouseOrThrow(houseId); + if (house.adminId !== profileId) { + throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it"); + } + + // Members' houseId also cascades to null via the FK's onDelete: SetNull, + // but clearing it explicitly first keeps the outcome obvious without + // relying on that FK behavior being read alongside this function. + await prisma.$transaction([ + prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }), + prisma.house.delete({ where: { id: house.id } }), + ]); +} + +/** + * Removes one specific member from the caller's household — the admin + * acting on someone else. The admin can't remove themselves this way (no + * adminship to hand off here); they use {@link leaveCurrentHouse} instead, + * same as any other member leaving voluntarily. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. + * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin. + * @throws {HttpError} `400 VALIDATION_ERROR` if `targetMemberId` is the caller, or isn't a member of this household. + */ +export async function removeMember( + profileId: number, + houseId: number | null, + targetMemberId: number, +): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + const house = await findHouseOrThrow(houseId); + if (house.adminId !== profileId) { + throw new HttpError( + 403, + ErrorCode.NOT_HOUSE_ADMIN, + "Only the household's admin can remove a member", + ); + } + if (targetMemberId === profileId) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + "Use POST /house/leave to remove yourself", + ); + } + if (!house.members.some((member) => member.id === targetMemberId)) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household"); + } + + await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } }); + return getCurrentHouseOrThrow(house.id); +} + +/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */ +async function getCurrentHouseOrThrow(houseId: number): Promise { + return toHouseView(await findHouseOrThrow(houseId)); +} + +/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */ +function isUniqueInviteCodeViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: unknown }).code === "P2002" + ); } /** @@ -31,8 +264,11 @@ export async function renameHouse(houseId: number | null, name: string): Promise * through the API, hence a plain `Error` (500) rather than a * `HOUSE_NOT_FOUND` HttpError. */ -async function findHouseOrThrow(houseId: number): Promise { - const house = await prisma.house.findUnique({ where: { id: houseId } }); +async function findHouseOrThrow(houseId: number) { + const house = await prisma.house.findUnique({ + where: { id: houseId }, + include: houseWithMembers, + }); if (!house) { throw new Error(`House ${houseId} referenced by a profile but not found`); } 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({ diff --git a/apps/web/cypress/e2e/account.cy.ts b/apps/web/cypress/e2e/account.cy.ts new file mode 100644 index 0000000..65a79da --- /dev/null +++ b/apps/web/cypress/e2e/account.cy.ts @@ -0,0 +1,68 @@ +import { ErrorCode } from "@batch-cooking/shared"; + +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, +}; + +describe("Account settings (/parametres/compte)", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + }); + + it("shows the signed-in profile's identity", () => { + cy.visit("/parametres/compte"); + + cy.contains("Alice").should("be.visible"); + cy.contains("Martin").should("be.visible"); + cy.contains("alice@example.com").should("be.visible"); + }); + + it("shows an error and keeps the session when the password is wrong", () => { + cy.intercept("DELETE", "**/auth/me", { + statusCode: 401, + body: { code: ErrorCode.INVALID_CREDENTIALS, message: "Invalid password" }, + }).as("deleteAccount"); + + cy.visit("/parametres/compte"); + cy.contains("button", "Supprimer mon compte").click(); + cy.get("#deleteAccountPassword").type("wrong-password"); + cy.contains("button", "Confirmer la suppression").click(); + + cy.wait("@deleteAccount"); + cy.contains("Email ou mot de passe incorrect").should("be.visible"); + cy.url().should("include", "/parametres/compte"); + }); + + it("deletes the account and returns to the login page", () => { + cy.intercept("DELETE", "**/auth/me", { statusCode: 204 }).as("deleteAccount"); + + cy.visit("/parametres/compte"); + cy.contains("button", "Supprimer mon compte").click(); + cy.get("#deleteAccountPassword").type("correct-horse-battery-staple"); + cy.contains("button", "Confirmer la suppression").click(); + + cy.wait("@deleteAccount") + .its("request.body") + .should("deep.equal", { password: "correct-horse-battery-staple" }); + cy.url().should("include", "/login"); + }); + + it("cancels the deletion without calling the API", () => { + cy.intercept("DELETE", "**/auth/me").as("deleteAccount"); + + cy.visit("/parametres/compte"); + cy.contains("button", "Supprimer mon compte").click(); + cy.contains("button", "Annuler").click(); + + cy.contains("button", "Confirmer la suppression").should("not.exist"); + cy.get("@deleteAccount.all").should("have.length", 0); + }); +}); diff --git a/apps/web/cypress/e2e/auth.cy.ts b/apps/web/cypress/e2e/auth.cy.ts index 40250ac..9be4fde 100644 --- a/apps/web/cypress/e2e/auth.cy.ts +++ b/apps/web/cypress/e2e/auth.cy.ts @@ -6,14 +6,11 @@ import { ErrorCode } from "@batch-cooking/shared"; // suites against a real database. describe("Signup", () => { - it("creates a profile and starts the onboarding wizard (household/regime/allergens)", () => { + it("creates a profile and starts the onboarding wizard (regime/household/allergens)", () => { cy.intercept("GET", "**/auth/me", { statusCode: 401 }); // The onboarding wizard's first step (see onboarding.cy.ts for the full - // walkthrough) reads the household right away to prefill its name field. - cy.intercept("GET", "**/house/current", { - statusCode: 200, - body: { id: 1, name: "Foyer de Alice" }, - }); + // walkthrough) is the regime step, which fetches the reference list. + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: { @@ -22,7 +19,7 @@ describe("Signup", () => { lastName: "Martin", email: "alice@example.com", tokenVersion: 0, - houseId: 1, + houseId: null, dietId: null, }, }).as("signup"); @@ -38,8 +35,8 @@ describe("Signup", () => { // Not the home page directly — signup hands off to the onboarding // wizard first (RedirectIfAuthenticated no longer applies here, it's a // RequireAuth-gated route of its own, see App.tsx). - cy.url().should("include", "/onboarding/foyer"); - cy.get("#houseName").should("have.value", "Foyer de Alice"); + cy.url().should("include", "/onboarding/regime"); + cy.contains("Étape 1 sur 3").should("be.visible"); }); it("shows a client-side validation error without calling the API", () => { @@ -157,6 +154,9 @@ describe("Already authenticated", () => { cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); cy.visit("/"); + // "Se déconnecter" lives inside the account menu, opened by clicking + // the greeting button — see AppLayout.tsx's AccountMenu. + cy.contains("button", "Bonjour Alice").click(); cy.contains("button", "Se déconnecter").click(); cy.wait("@logout"); diff --git a/apps/web/cypress/e2e/home-planning.cy.ts b/apps/web/cypress/e2e/home-planning.cy.ts index 7f336c0..01e1d02 100644 --- a/apps/web/cypress/e2e/home-planning.cy.ts +++ b/apps/web/cypress/e2e/home-planning.cy.ts @@ -19,6 +19,8 @@ describe("Sidebar navigation", () => { cy.visit("/"); }); + // The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres" + // toggle, not the main nav tested here — are covered by sidebar.cy.ts. it("highlights the current section and navigates between stub pages", () => { cy.contains("nav a", "Planning").should("have.class", "active"); @@ -32,19 +34,15 @@ describe("Sidebar navigation", () => { cy.url().should("include", "/liste-de-courses"); cy.contains("h1", "Liste de courses").should("be.visible"); - cy.contains("nav a", "Foyer & profil").click(); - cy.url().should("include", "/foyer"); - cy.contains("h1", "Foyer & profil").should("be.visible"); - cy.contains("nav a", "Planning").click(); cy.url().should("eq", `${Cypress.config().baseUrl}/`); cy.contains("h1", "Planning de la semaine").should("be.visible"); }); - it("shows the signed-in user's name and lets them log out from the sidebar", () => { + it("shows the signed-in user's name and lets them log out from the account menu", () => { cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout"); - cy.contains("Bonjour Alice").should("be.visible"); + cy.contains("button", "Bonjour Alice").should("be.visible").click(); cy.contains("button", "Se déconnecter").click(); cy.wait("@logout"); diff --git a/apps/web/cypress/e2e/household-settings.cy.ts b/apps/web/cypress/e2e/household-settings.cy.ts new file mode 100644 index 0000000..eb4acd1 --- /dev/null +++ b/apps/web/cypress/e2e/household-settings.cy.ts @@ -0,0 +1,185 @@ +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. + +const adminProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null as number | null, + dietId: null, +}; + +const houseWithTwoMembers = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [ + { id: 1, firstName: "Alice", lastName: "Martin" }, + { id: 2, firstName: "Bob", lastName: "Dupont" }, + ], +}; + +describe("Household settings (/parametres/foyer) — no household yet", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: adminProfile }); + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); + }); + + it("offers to create or join a household", () => { + cy.visit("/parametres/foyer"); + + cy.contains("Créer un foyer").should("be.visible"); + cy.contains("Rejoindre un foyer").should("be.visible"); + }); + + it("creates a household", () => { + const createdHouse = { + id: 1, + name: "Chez Alice", + adminId: 1, + inviteCode: "ABCD2345", + members: [{ id: 1, firstName: "Alice", lastName: "Martin" }], + }; + // The page reloads `GET /house/current` right after creating succeeds — + // see the "deletes the household" test above for the same pattern. + let created = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: created ? createdHouse : null }); + }); + cy.intercept("POST", "**/house", (req) => { + created = true; + req.reply({ statusCode: 201, body: createdHouse }); + }).as("createHouse"); + + cy.visit("/parametres/foyer"); + cy.get("#houseName").type("Chez Alice"); + cy.contains("button", "Créer").click(); + + cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); + cy.contains("ABCD2345").should("be.visible"); + }); + + it("joins a household by invite code", () => { + let joined = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: joined ? houseWithTwoMembers : null }); + }); + cy.intercept("POST", "**/house/join", (req) => { + joined = true; + req.reply({ statusCode: 200, body: houseWithTwoMembers }); + }).as("joinHouse"); + + cy.visit("/parametres/foyer"); + cy.get("#inviteCode").type("abcd2345"); + cy.contains("button", "Rejoindre").click(); + + cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); + cy.contains("Bob Dupont").should("be.visible"); + }); +}); + +describe("Household settings (/parametres/foyer) — as the admin", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: { ...adminProfile, houseId: 1 } }); + cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers }); + }); + + it("shows the household's name, invite code, and members with an admin badge", () => { + cy.visit("/parametres/foyer"); + + cy.get("#houseName").should("have.value", "Chez Alice"); + cy.contains("ABCD2345").should("be.visible"); + cy.contains("Bob Dupont").should("be.visible"); + cy.contains("Alice Martin").parent().contains("Admin"); + }); + + it("autosaves the household name", () => { + cy.intercept("PATCH", "**/house/current", { + statusCode: 200, + body: { ...houseWithTwoMembers, name: "Chez les Martin" }, + }).as("renameHouse"); + + cy.visit("/parametres/foyer"); + cy.get("#houseName").clear(); + cy.get("#houseName").type("Chez les Martin"); + + cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" }); + cy.contains("Enregistré ✓").should("be.visible"); + }); + + it("removes a member", () => { + // Same reasoning as the "deletes the household" test below — the page + // reloads `GET /house/current` right after the removal succeeds. + let memberRemoved = false; + cy.intercept("GET", "**/house/current", (req) => { + const body = memberRemoved + ? { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] } + : houseWithTwoMembers; + req.reply({ statusCode: 200, body }); + }); + cy.intercept("DELETE", "**/house/members/2", (req) => { + memberRemoved = true; + req.reply({ + statusCode: 200, + body: { ...houseWithTwoMembers, members: [houseWithTwoMembers.members[0]] }, + }); + }).as("removeMember"); + + cy.visit("/parametres/foyer"); + cy.contains("li", "Bob Dupont").contains("button", "Retirer").click(); + + cy.wait("@removeMember"); + cy.contains("Bob Dupont").should("not.exist"); + }); + + it("deletes the household after confirming", () => { + // The page reloads `GET /house/current` right after the delete + // succeeds — this intercept needs to answer differently before/after + // that DELETE, hence the shared mutable flag rather than two static + // `cy.intercept` calls (the later one would just win for every request, + // including the initial page load). + let houseDeleted = false; + cy.intercept("GET", "**/house/current", (req) => { + req.reply({ statusCode: 200, body: houseDeleted ? null : houseWithTwoMembers }); + }); + cy.intercept("DELETE", "**/house/current", (req) => { + houseDeleted = true; + req.reply({ statusCode: 204 }); + }).as("deleteHouse"); + + cy.visit("/parametres/foyer"); + cy.contains("button", "Supprimer le foyer").click(); + cy.contains("button", "Confirmer la suppression").click(); + + cy.wait("@deleteHouse"); + cy.contains("Créer un foyer").should("be.visible"); + }); +}); + +describe("Household settings (/parametres/foyer) — as a non-admin member", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { + statusCode: 200, + body: { ...adminProfile, id: 2, firstName: "Bob", lastName: "Dupont", houseId: 1 }, + }); + cy.intercept("GET", "**/house/current", { statusCode: 200, body: houseWithTwoMembers }); + }); + + it("offers to leave the household instead of deleting it", () => { + cy.visit("/parametres/foyer"); + + cy.contains("button", "Quitter le foyer").should("be.visible"); + cy.contains("button", "Supprimer le foyer").should("not.exist"); + }); + + it("leaves the household", () => { + cy.intercept("POST", "**/house/leave", { statusCode: 204 }).as("leaveHouse"); + + cy.visit("/parametres/foyer"); + cy.contains("button", "Quitter le foyer").click(); + + cy.wait("@leaveHouse"); + }); +}); diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts index 39dbd0f..8906290 100644 --- a/apps/web/cypress/e2e/onboarding.cy.ts +++ b/apps/web/cypress/e2e/onboarding.cy.ts @@ -8,22 +8,27 @@ const signupResponse = { lastName: "Martin", email: "alice@example.com", tokenVersion: 0, - houseId: 1, + houseId: null as number | null, dietId: null, }; -describe("Onboarding wizard (household → regime → allergens)", () => { - it("walks through all three steps after signup and lands on the home", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); - cy.intercept("GET", "**/house/current", { - statusCode: 200, - body: { id: 1, name: "Foyer de Alice" }, - }); - cy.intercept("PATCH", "**/house/current", { - statusCode: 200, - body: { id: 1, name: "Chez Alice" }, - }).as("renameHouse"); +/** Signs up and lands on the wizard's first step (regime) — shared setup for every scenario below. */ +function signupAndReachOnboarding() { + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }).as("signup"); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + + cy.visit("/signup"); + cy.get("#firstName").type("Alice"); + cy.get("#lastName").type("Martin"); + cy.get("#email").type("alice@example.com"); + cy.get("#password").type("correct-horse-battery-staple"); + cy.contains("button", "Créer mon profil").click(); + cy.wait("@signup"); +} + +describe("Onboarding wizard (regime → foyer → allergens)", () => { + it("walks through all three steps, creating a household on the way, and lands on the home", () => { cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [ @@ -35,6 +40,11 @@ describe("Onboarding wizard (household → regime → allergens)", () => { statusCode: 200, body: { ...signupResponse, dietId: 2 }, }).as("updateDiet"); + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); + cy.intercept("POST", "**/house", { + statusCode: 201, + body: { id: 1, name: "Chez Alice", adminId: 1, inviteCode: "ABCD2345", members: [] }, + }).as("createHouse"); cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [ @@ -45,32 +55,23 @@ describe("Onboarding wizard (household → regime → allergens)", () => { cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as( "updateAllergies", ); - cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - cy.wait("@signup"); + signupAndReachOnboarding(); - // Step 1/3 — household name, prefilled with the auto-generated default. - cy.url().should("include", "/onboarding/foyer"); - cy.contains("Étape 1 sur 3").should("be.visible"); - cy.get("#houseName").should("have.value", "Foyer de Alice"); - cy.get("#houseName").clear(); - cy.get("#houseName").type("Chez Alice"); - cy.contains("button", "Continuer").click(); - cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); - - // Step 2/3 — dietary regime. + // Step 1/3 — dietary regime. cy.url().should("include", "/onboarding/regime"); - cy.contains("Étape 2 sur 3").should("be.visible"); + cy.contains("Étape 1 sur 3").should("be.visible"); cy.get("#diet").select("Végétarien"); cy.contains("button", "Continuer").click(); cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 }); + // Step 2/3 — household, optional: creating one here. + cy.url().should("include", "/onboarding/foyer"); + cy.contains("Étape 2 sur 3").should("be.visible"); + cy.get("#houseName").type("Chez Alice"); + cy.contains("button", "Créer").click(); + cy.wait("@createHouse").its("request.body").should("deep.equal", { name: "Chez Alice" }); + // Step 3/3 — allergens (grouped into two lists) and intolerances, then finish. cy.url().should("include", "/onboarding/allergenes"); cy.contains("Étape 3 sur 3").should("be.visible"); @@ -86,46 +87,62 @@ describe("Onboarding wizard (household → regime → allergens)", () => { cy.contains("h1", "Planning de la semaine").should("be.visible"); }); - it("lets every step be skipped without changing anything", () => { - cy.intercept("GET", "**/auth/me", { statusCode: 401 }); - cy.intercept("POST", "**/auth/signup", { statusCode: 201, body: signupResponse }); - cy.intercept("GET", "**/house/current", { - statusCode: 200, - body: { id: 1, name: "Foyer de Alice" }, - }); - cy.intercept("PATCH", "**/house/current", { - statusCode: 200, - body: { id: 1, name: "Foyer de Alice" }, - }).as("renameHouse"); + it("lets the regime and allergens steps be skipped without changing anything", () => { cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }).as( "updateDiet", ); + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }).as( "updateAllergies", ); - cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); - cy.visit("/signup"); - cy.get("#firstName").type("Alice"); - cy.get("#lastName").type("Martin"); - cy.get("#email").type("alice@example.com"); - cy.get("#password").type("correct-horse-battery-staple"); - cy.contains("button", "Créer mon profil").click(); - - cy.url().should("include", "/onboarding/foyer"); - cy.contains("button", "Continuer").click(); - cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Foyer de Alice" }); + signupAndReachOnboarding(); cy.url().should("include", "/onboarding/regime"); cy.contains("button", "Continuer").click(); cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: null }); + cy.url().should("include", "/onboarding/foyer"); + cy.contains("button", "Passer cette étape").click(); + cy.url().should("include", "/onboarding/allergenes"); cy.contains("button", "Terminer").click(); cy.wait("@updateAllergies").its("request.body").should("deep.equal", { allergyIds: [] }); cy.url().should("eq", `${Cypress.config().baseUrl}/`); }); + + it("lets the household step be completed by joining an existing household instead of creating one", () => { + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [] }); + cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: signupResponse }); + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); + cy.intercept("POST", "**/house/join", { + statusCode: 200, + body: { + id: 1, + name: "Chez Bob", + adminId: 2, + inviteCode: "ABCD2345", + members: [ + { id: 1, firstName: "Alice", lastName: "Martin" }, + { id: 2, firstName: "Bob", lastName: "Dupont" }, + ], + }, + }).as("joinHouse"); + cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] }); + cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [] }); + + signupAndReachOnboarding(); + + cy.contains("button", "Continuer").click(); + cy.url().should("include", "/onboarding/foyer"); + + cy.get("#inviteCode").type("abcd2345"); + cy.contains("button", "Rejoindre").click(); + + cy.wait("@joinHouse").its("request.body").should("deep.equal", { inviteCode: "ABCD2345" }); + cy.url().should("include", "/onboarding/allergenes"); + }); }); diff --git a/apps/web/cypress/e2e/household.cy.ts b/apps/web/cypress/e2e/preferences.cy.ts similarity index 62% rename from apps/web/cypress/e2e/household.cy.ts rename to apps/web/cypress/e2e/preferences.cy.ts index 9255797..698a219 100644 --- a/apps/web/cypress/e2e/household.cy.ts +++ b/apps/web/cypress/e2e/preferences.cy.ts @@ -10,13 +10,9 @@ const authenticatedProfile = { dietId: 2, }; -describe("Household & profile settings (/foyer) — hot saving", () => { +describe("Dietary preferences (/parametres/preferences) — hot saving", () => { beforeEach(() => { cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); - cy.intercept("GET", "**/house/current", { - statusCode: 200, - body: { id: 1, name: "Chez Alice" }, - }); cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [ @@ -34,10 +30,9 @@ describe("Household & profile settings (/foyer) — hot saving", () => { cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); }); - it("loads the current household name, regime, and shows allergies/intolerances as two groups", () => { - cy.visit("/foyer"); + it("loads the current regime, and shows allergies/intolerances as two groups", () => { + cy.visit("/parametres/preferences"); - cy.get("#houseName").should("have.value", "Chez Alice"); cy.get("#diet").should("have.value", "2"); cy.contains("legend", "Allergies").should("be.visible"); cy.contains("legend", "Intolérances").should("be.visible"); @@ -46,41 +41,17 @@ describe("Household & profile settings (/foyer) — hot saving", () => { }); it("has no explicit save button anywhere on the page", () => { - cy.visit("/foyer"); + cy.visit("/parametres/preferences"); cy.contains("button", "Enregistrer").should("not.exist"); }); - it("autosaves the household name a short pause after typing, no button click", () => { - cy.intercept("PATCH", "**/house/current", { - statusCode: 200, - body: { id: 1, name: "Chez les Martin" }, - }).as("renameHouse"); - - cy.visit("/foyer"); - cy.get("#houseName").clear(); - cy.get("#houseName").type("Chez les Martin"); - - cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" }); - cy.contains("Enregistré ✓").should("be.visible"); - }); - - it("does not autosave an empty household name — shows a validation message instead", () => { - cy.intercept("PATCH", "**/house/current").as("renameHouse"); - - cy.visit("/foyer"); - cy.get("#houseName").clear(); - - cy.contains("Le nom du foyer est requis").should("be.visible"); - cy.get("@renameHouse.all").should("have.length", 0); - }); - it("autosaves the regime as soon as it's selected", () => { cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: { ...authenticatedProfile, dietId: 1 }, }).as("updateDiet"); - cy.visit("/foyer"); + cy.visit("/parametres/preferences"); cy.get("#diet").select("Omnivore"); cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 }); @@ -91,11 +62,12 @@ describe("Household & profile settings (/foyer) — hot saving", () => { "updateAllergies", ); - cy.visit("/foyer"); + cy.visit("/parametres/preferences"); cy.contains("label", "Arachides").find("input[type=checkbox]").check(); cy.wait("@updateAllergies") .its("request.body") .should("deep.equal", { allergyIds: [2, 1] }); + cy.contains("Enregistré ✓").should("be.visible"); }); }); diff --git a/apps/web/cypress/e2e/sidebar.cy.ts b/apps/web/cypress/e2e/sidebar.cy.ts new file mode 100644 index 0000000..07295d2 --- /dev/null +++ b/apps/web/cypress/e2e/sidebar.cy.ts @@ -0,0 +1,50 @@ +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale. + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, +}; + +describe("Sidebar — settings menu and account menu", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null }); + }); + + it("no longer lists Foyer in the main nav", () => { + cy.visit("/"); + cy.get(".app-sidebar__nav a").should("not.contain", "Foyer"); + }); + + it("reveals the three settings pages behind the Paramètres toggle", () => { + cy.visit("/"); + + cy.contains("a", "Compte").should("not.exist"); + cy.contains("button", "Paramètres").click(); + + cy.contains("a", "Compte").should("have.attr", "href", "/parametres/compte"); + cy.contains("a", "Préférences").should("have.attr", "href", "/parametres/preferences"); + cy.contains("a", "Foyer").should("have.attr", "href", "/parametres/foyer"); + }); + + it("opens the account menu from the greeting and links to Mon compte", () => { + cy.visit("/"); + + cy.contains("a", "Mon compte").should("not.exist"); + cy.contains("button", "Bonjour Alice").click(); + cy.contains("a", "Mon compte").click(); + + cy.url().should("include", "/parametres/compte"); + }); + + it("redirects the old /foyer path to /parametres/foyer", () => { + cy.intercept("GET", "**/house/current", { statusCode: 200, body: null }); + cy.visit("/foyer"); + cy.url().should("include", "/parametres/foyer"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 94cc69c..ec6d75f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,7 +3,6 @@ import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated import { RequireAuth } from "./features/auth/RequireAuth"; import { AppLayout } from "./layouts/AppLayout"; import { HomePage } from "./pages/HomePage"; -import { HouseholdPage } from "./pages/HouseholdPage"; import { LoginPage } from "./pages/LoginPage"; import { RecipesPage } from "./pages/RecipesPage"; import { ShoppingListPage } from "./pages/ShoppingListPage"; @@ -11,6 +10,9 @@ import { SignupPage } from "./pages/SignupPage"; import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage"; import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; +import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; +import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; +import { PreferencesPage } from "./pages/settings/PreferencesPage"; /** * Top-level route table. Every authenticated section is nested under one @@ -20,11 +22,18 @@ import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdP * {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which * itself redirects to `/login` if needed. * - * `/onboarding/*` (household/regime/allergens) is also `RequireAuth`-gated - * — reached right after signup, once a session already exists — but - * deliberately its own top-level route group, *not* nested under - * `AppLayout`: a focused, distraction-free wizard with no sidebar, same - * full-page-card language as `/login`/`/signup` (see `onboarding.scss`). + * `/parametres/*` (compte/préférences/foyer) are the settings pages, + * reachable from the sidebar's bottom "Paramètres" menu and the account + * menu (see `AppLayout`) — nested under `AppLayout` like every other + * authenticated section. `/foyer` is the old, pre-split combined page's + * path; it now just redirects to `/parametres/foyer` so an existing + * bookmark/link keeps working. + * + * `/onboarding/*` (regime/foyer/allergens, in that order) is also + * `RequireAuth`-gated — reached right after signup, once a session already + * exists — but deliberately its own top-level route group, *not* nested + * under `AppLayout`: a focused, distraction-free wizard with no sidebar, + * same full-page-card language as `/login`/`/signup` (see `onboarding.scss`). */ export function App() { return ( @@ -39,16 +48,11 @@ export function App() { } /> } /> } /> - } /> + } /> + } /> + } /> + } /> - - - - } - /> } /> + + + + } + /> { + return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); + } + /** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */ public getCurrentPlanning(): Promise { return this.request("/planning/current"); @@ -113,7 +118,7 @@ export class ApiClient { return this.request("/reference/allergies"); } - /** Fetches the current user's household. */ + /** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */ public getCurrentHouse(): Promise { return this.request("/house/current"); } @@ -123,6 +128,31 @@ export class ApiClient { return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) }); } + /** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */ + public createHouse(name: string): Promise { + return this.request("/house", { method: "POST", body: JSON.stringify({ name }) }); + } + + /** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */ + public joinHouse(inviteCode: string): Promise { + return this.request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) }); + } + + /** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */ + public leaveHouse(): Promise { + return this.request("/house/leave", { method: "POST" }); + } + + /** Deletes the current user's household outright — every member loses it. Admin-only. */ + public deleteHouse(): Promise { + return this.request("/house/current", { method: "DELETE" }); + } + + /** Removes one specific member from the current user's household. Admin-only. */ + public removeHouseMember(memberId: number): Promise { + return this.request(`/house/members/${memberId}`, { method: "DELETE" }); + } + /** Sets (or clears, with `null`) the current user's dietary regime. */ public updateDiet(dietId: number | null): Promise { return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) }); diff --git a/apps/web/src/features/auth/AuthContext.tsx b/apps/web/src/features/auth/AuthContext.tsx index cca04ac..1a8b58a 100644 --- a/apps/web/src/features/auth/AuthContext.tsx +++ b/apps/web/src/features/auth/AuthContext.tsx @@ -14,11 +14,13 @@ interface AuthContextValue { login: (input: LoginInput) => Promise; /** Ends the session and clears `user`. */ logout: () => Promise; + /** Permanently deletes the current account and clears `user`. Throws `ApiError` (e.g. wrong password) on failure. */ + deleteAccount: (password: string) => Promise; /** * Re-fetches the current profile and updates `user`. Needed after * anything that changes profile fields `user` carries (e.g. `dietId`) * outside of `signup`/`login` — `PATCH /profile/diet` (see - * `HouseholdPage.tsx`) updates the database directly via `apiClient`, + * `PreferencesPage.tsx`) updates the database directly via `apiClient`, * which doesn't touch this context on its own. */ refreshUser: () => Promise; @@ -59,12 +61,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); }, []); + const deleteAccount = useCallback(async (password: string) => { + await apiClient.deleteAccount(password); + setUser(null); + }, []); + const refreshUser = useCallback(async () => { setUser(await apiClient.me()); }, []); return ( - + {children} ); diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx index d271ac3..0141dae 100644 --- a/apps/web/src/features/profile/AllergySelect.tsx +++ b/apps/web/src/features/profile/AllergySelect.tsx @@ -12,8 +12,8 @@ interface AllergySelectProps { * Multi-select (checkbox grid, not a native ` onChange(e.target.value === "" ? null : Number(e.target.value))} > - + {diets.map((diet) => (