Tests API: couverture Mocha + Cucumber pour le foyer et la suppression de compte (step 4/8)
- 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
This commit is contained in:
parent
7af98756dc
commit
3363cfad75
9 changed files with 516 additions and 23 deletions
|
|
@ -33,3 +33,18 @@ Feature: Account creation and login
|
||||||
When I log in with email "alice@example.com" and password "wrong-password"
|
When I log in with email "alice@example.com" and password "wrong-password"
|
||||||
Then the response status should be 401
|
Then the response status should be 401
|
||||||
And the response error code should be "INVALID_CREDENTIALS"
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,67 @@
|
||||||
Feature: Household name
|
Feature: Household
|
||||||
As a signed-in user
|
As a signed-in user
|
||||||
I want to name my household
|
I want to name my household, invite others to it, and manage its members
|
||||||
So that it's recognizable as ours, not the auto-generated default
|
So that my whole household can share the same planning
|
||||||
|
|
||||||
Scenario: A visitor without a session cannot read the household
|
Scenario: A visitor without a session cannot read the household
|
||||||
When I send a GET request to "/house/current"
|
When I send a GET request to "/house/current"
|
||||||
Then the response status should be 401
|
Then the response status should be 401
|
||||||
And the response error code should be "NOT_AUTHENTICATED"
|
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
|
Scenario: A signed-in user renames their household
|
||||||
Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple"
|
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 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"
|
When I rename my household to "Chez les Martin"
|
||||||
Then the response status should be 200
|
Then the response status should be 200
|
||||||
And my household should be named "Chez les Martin"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
Then("I am authenticated as {string}", async function (this: CustomWorld, email: string) {
|
||||||
const res = await this.agent.get("/auth/me");
|
const res = await this.agent.get("/auth/me");
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.body.email, email);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,90 @@
|
||||||
import assert from "node:assert/strict";
|
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";
|
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) {
|
When("I rename my household to {string}", async function (this: CustomWorld, name: string) {
|
||||||
this.response = await this.agent.patch("/house/current").send({ name });
|
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) {
|
Then("my household should be named {string}", async function (this: CustomWorld, name: string) {
|
||||||
const res = await this.agent.get("/house/current");
|
const res = await this.agent.get("/house/current");
|
||||||
assert.equal(res.body.name, name);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
// the API — there's no "create a planning" endpoint yet (see
|
||||||
// specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still
|
// 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
|
// 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
|
// has one. A household is no longer created implicitly at signup, so this
|
||||||
// `GET /auth/me`) rather than taking it as a step argument, since the
|
// step creates one via `POST /house` first — the scenario never names it
|
||||||
// scenario never names it explicitly.
|
// explicitly, its name doesn't matter here.
|
||||||
Given(
|
Given(
|
||||||
"my household has a planning covering today with recipe {string} on {string} for {string}",
|
"my household has a planning covering today with recipe {string} on {string} for {string}",
|
||||||
async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) {
|
||||||
const me = await this.agent.get("/auth/me");
|
const houseRes = await this.agent.post("/house").send({ name: "Foyer de test" });
|
||||||
const houseId: number = me.body.houseId;
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
|
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ export class CustomWorld extends World {
|
||||||
app: Express;
|
app: Express;
|
||||||
agent: ReturnType<typeof request.agent>;
|
agent: ReturnType<typeof request.agent>;
|
||||||
response!: request.Response;
|
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<typeof request.agent>;
|
||||||
|
secondResponse!: request.Response;
|
||||||
|
|
||||||
constructor(options: IWorldOptions) {
|
constructor(options: IWorldOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
this.app = createApp();
|
this.app = createApp();
|
||||||
this.agent = request.agent(this.app);
|
this.agent = request.agent(this.app);
|
||||||
|
this.secondAgent = request.agent(this.app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ describe("Auth", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /auth/signup", () => {
|
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 payload = buildSignupPayload();
|
||||||
const res = await request(app).post("/auth/signup").send(payload);
|
const res = await request(app).post("/auth/signup").send(payload);
|
||||||
|
|
||||||
|
|
@ -49,7 +49,9 @@ describe("Auth", () => {
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
});
|
});
|
||||||
expect(res.body).to.not.have.property("passwordHash");
|
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=");
|
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -131,4 +133,75 @@ describe("Auth", () => {
|
||||||
expect(res.body.email).to.equal(payload.email);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
|
import type { Express } from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.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", () => {
|
describe("Household", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -36,14 +44,28 @@ describe("Household", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the household created at signup", async () => {
|
it("returns null when the profile has no household yet", async () => {
|
||||||
const agent = request.agent(app);
|
const { agent } = await signupAgent(app);
|
||||||
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/house/current");
|
const res = await agent.get("/house/current");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
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);
|
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 () => {
|
it("renames the household", async () => {
|
||||||
const agent = request.agent(app);
|
const { agent } = await signupAgent(app);
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/house").send({ name: "Chez Alice" });
|
||||||
|
|
||||||
const res = await agent.patch("/house/current").send({ name: "Chez les Dupont" });
|
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 () => {
|
it("rejects an empty name with 400 VALIDATION_ERROR", async () => {
|
||||||
const agent = request.agent(app);
|
const { agent } = await signupAgent(app);
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/house").send({ name: "Chez Alice" });
|
||||||
|
|
||||||
const res = await agent.patch("/house/current").send({ name: "" });
|
const res = await agent.patch("/house/current").send({ name: "" });
|
||||||
|
|
||||||
|
|
@ -78,4 +109,231 @@ describe("Household", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,9 @@ describe("Planning", () => {
|
||||||
|
|
||||||
it("returns the household's planning covering today, with recipes resolved", async () => {
|
it("returns the household's planning covering today, with recipes resolved", async () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
const houseId: number = signupRes.body.houseId;
|
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 recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
@ -80,8 +81,9 @@ describe("Planning", () => {
|
||||||
|
|
||||||
it("returns null when the household's planning does not cover today", async () => {
|
it("returns null when the household's planning does not cover today", async () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
const houseId: number = signupRes.body.houseId;
|
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".
|
// A planning entirely in the past — shouldn't be picked up as "current".
|
||||||
await prisma.planning.create({
|
await prisma.planning.create({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue