Five explicit review points, addressed on this same PR branch (not a new PR) per updated preference. ## No .d.ts files in the codebase - apps/web: vite-env.d.ts removed — its /// <reference types="vite/client" /> is replaced by "types": ["vite/client"] in tsconfig.app.json, same effect. - apps/api: src/types/express.d.ts renamed to express-request.augment.ts — `declare global` module augmentation works identically in a plain .ts file as long as it has a top-level import (making it a module); the .d.ts extension wasn't doing anything for us here. ## packages/express-tools — separate package for Express tooling Moved HttpError and ErrorHandlerService out of apps/api into a new workspace package, plus a new createErrorMiddleware() factory (the actual Express 4-arg error-handling middleware, previously inlined in app.ts). apps/api now just consumes @batch-cooking/express-tools. Has a real build (tsc -> dist/, same pattern as packages/shared) — required for the same reason shared needed one: apps/api's Docker image runs plain `node dist/server.js`, no tsx. apps/api/Dockerfile updated to COPY the new package's dist alongside shared's. ## faker.js for test fixtures apps/api/test/auth.test.ts: replaced the hardcoded "Nicolas Lefevre"/nicolas@example.com fixture (looked like real user data) with @faker-js/faker, generated fresh per test via buildSignupPayload(). features/step-definitions/auth.steps.ts: fakerized the filler firstName/lastName/password used for background state the scenarios don't actually read. Deliberately did NOT fakerize the literal example values inside auth.feature itself (alice@example.com etc.) — those are the readable, illustrative Gherkin examples that are the whole point of BDD scenarios, not real PII, and randomizing them would make the scenarios harder to read for no real gain. Flagged this reasoning in the README in case that call should go the other way. Caught a real bug while wiring this up: faker.internet.email() sometimes capitalizes parts of the address, but signupSchema/loginSchema normalize emails to lowercase — the test fixture needs to match what's actually stored, so buildSignupPayload() lowercases the generated email too. Found by actually running the suite repeatedly, not just once. ## ErrorCode: numeric enum, zero hardcoded values packages/shared/src/errors/error-codes.ts: ErrorCode is now a numeric enum (4000 VALIDATION_ERROR, 4001 EMAIL_ALREADY_IN_USE, 4010 INVALID_CREDENTIALS, 4011 NOT_AUTHENTICATED, 4040 NOT_FOUND, 5000 INTERNAL_ERROR — grouped by family like HTTP status codes). Audited and fixed every place that hardcoded a raw code value instead of referencing the enum: ApiClient's fallback (`"INTERNAL_ERROR" as ErrorCode` — would no longer even type-check once the enum went numeric, which is exactly the point), and the Cypress mock bodies (now import ErrorCode from @batch-cooking/shared instead of typing the string). Cucumber's "the response error code should be {string}" step still takes the *name* in the .feature file (readable: "EMAIL_ALREADY_IN_USE") and resolves it to the real numeric value via ErrorCode[name] — TypeScript's reverse enum mapping — before comparing, so the Gherkin stays readable without the step hardcoding a number either. ## Real i18n library (i18next), not a hand-rolled label map apps/web: added i18next + react-i18next. New locales/fr/translation.json holds every user-facing string — not just error labels (errors.*), but the login/signup/home pages' labels, buttons and headings too (auth.login.*, auth.signup.*, home.*) — via useTranslation()/t() in each page. ErrorMessageService no longer owns its own label map; it converts the numeric ErrorCode to its enum member name and delegates the actual lookup to i18next (errors.<MEMBER_NAME>). Adding a language is now "add a locale file", not a code change anywhere. ## specs/ and README updated specs/error-handling.md and specs/frontend-architecture.md rewritten for the new package, numeric codes, and i18next. New "i18n" and "no .d.ts" sections. README covers the same, plus a note on the faker.js scope decision (feature-file literals excluded, on purpose). ## Verification Full lint/mocha (x3 runs)/cucumber/build green. Re-verified express-tools' extraction against a real risk (not just tsc passing): ran `node dist/server.js` standalone (mirrors the Docker runtime, no tsx) and hit /health, a 404 (confirmed numeric code 4040 over the wire), and a real signup + duplicate-email 409 (confirmed numeric 4001). Then re-verified the full pipeline in a real browser against native dev servers: signup, EMAIL_ALREADY_IN_USE -> i18next -> "Cet email est déjà utilisé" end-to-end, home page i18next interpolation ({{firstName}}/{{lastName}}) rendering correctly.
134 lines
4.6 KiB
TypeScript
134 lines
4.6 KiB
TypeScript
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
|
import { faker } from "@faker-js/faker";
|
|
import { expect } from "chai";
|
|
import request from "supertest";
|
|
import { createApp } from "../src/app.js";
|
|
import { prisma } from "../src/db/prisma.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
/**
|
|
* Builds a fresh, fake (never real-looking) signup payload. Called anew per
|
|
* test rather than sharing one module-level constant, so tests never
|
|
* accidentally depend on a specific fixture value and each run exercises
|
|
* different data — closer to how the app actually gets used.
|
|
*/
|
|
function buildSignupPayload(): SignupInput {
|
|
const firstName = faker.person.firstName();
|
|
const lastName = faker.person.lastName();
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
// Lowercased to match what signupSchema/loginSchema normalize the email
|
|
// to (`.toLowerCase()`) — faker sometimes capitalizes parts of it, and
|
|
// without this the fixture value stops matching what's actually stored.
|
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
password: faker.internet.password({ length: 16 }),
|
|
};
|
|
}
|
|
|
|
describe("Auth", () => {
|
|
const app = createApp();
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("POST /auth/signup", () => {
|
|
it("creates a profile and its house, and sets a session cookie", async () => {
|
|
const payload = buildSignupPayload();
|
|
const res = await request(app).post("/auth/signup").send(payload);
|
|
|
|
expect(res.status).to.equal(201);
|
|
expect(res.body).to.include({
|
|
firstName: payload.firstName,
|
|
lastName: payload.lastName,
|
|
email: payload.email,
|
|
});
|
|
expect(res.body).to.not.have.property("passwordHash");
|
|
expect(res.body.houseId).to.be.a("number");
|
|
expect(res.headers["set-cookie"]?.[0]).to.include("session=");
|
|
});
|
|
|
|
it("rejects a duplicate email with 409 EMAIL_ALREADY_IN_USE", async () => {
|
|
const payload = buildSignupPayload();
|
|
await request(app).post("/auth/signup").send(payload);
|
|
const res = await request(app).post("/auth/signup").send(payload);
|
|
|
|
expect(res.status).to.equal(409);
|
|
expect(res.body.code).to.equal(ErrorCode.EMAIL_ALREADY_IN_USE);
|
|
});
|
|
|
|
it("rejects an invalid payload with 400 VALIDATION_ERROR", async () => {
|
|
const res = await request(app).post("/auth/signup").send({
|
|
firstName: faker.person.firstName(),
|
|
lastName: faker.person.lastName(),
|
|
email: "not-an-email",
|
|
password: "short",
|
|
});
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
expect(res.body.details).to.have.keys(["email", "password"]);
|
|
});
|
|
});
|
|
|
|
describe("POST /auth/login", () => {
|
|
let payload: SignupInput;
|
|
|
|
beforeEach(async () => {
|
|
payload = buildSignupPayload();
|
|
await request(app).post("/auth/signup").send(payload);
|
|
});
|
|
|
|
it("logs in with correct credentials", async () => {
|
|
const res = await request(app)
|
|
.post("/auth/login")
|
|
.send({ email: payload.email, password: payload.password });
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.email).to.equal(payload.email);
|
|
});
|
|
|
|
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
|
const res = await request(app)
|
|
.post("/auth/login")
|
|
.send({ email: payload.email, password: faker.internet.password({ length: 16 }) });
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
|
});
|
|
|
|
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
|
const res = await request(app)
|
|
.post("/auth/login")
|
|
.send({ email: faker.internet.email(), password: payload.password });
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
|
});
|
|
});
|
|
|
|
describe("GET /auth/me", () => {
|
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
const res = await request(app).get("/auth/me");
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
});
|
|
|
|
it("returns the current profile when authenticated", async () => {
|
|
const payload = buildSignupPayload();
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(payload);
|
|
|
|
const res = await agent.get("/auth/me");
|
|
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.email).to.equal(payload.email);
|
|
});
|
|
});
|
|
});
|