## Error handling
Requested: a centralized error-handling service on the API, custom error
codes shared across apps, and a client-side error service for i18n labels.
- packages/shared/src/errors/error-codes.ts — ErrorCode enum + ApiErrorResponse
contract. Single source of truth: neither side hardcodes a raw error string
the other has to guess at.
- apps/api: HttpError now carries an ErrorCode (not just a message).
ErrorHandlerService (new) centralizes every "how do we turn a thrown error
into an HTTP response" decision — app.ts's error middleware is now a thin
adapter calling into it. API messages reverted to English/dev-facing (they
were French from an earlier pass) since user-facing text is now generated
client-side from the code.
- apps/web: ApiClient (class, singleton instance) throws ApiError carrying
the code. ErrorMessageService (new) maps every ErrorCode to a localized
label, structured with a Locale type from the start (only "fr" exists, but
adding a language later is "add a locale to the map", not "hunt down every
hardcoded string"). LoginPage/SignupPage now display
errorMessageService.getLabel(err.code), never err.message directly.
- Tests strengthened to assert on `code`, not just HTTP status (Mocha +
Cucumber, new "the response error code should be" step). Cypress mocks
updated to the new {code, message} response shape.
## Code quality pass
Per explicit feedback: heavy JSDoc on every interface/type/class/function/
method/member touched in this PR, explicit public/private visibility on
every class member (ApiClient, ErrorMessageService, ErrorHandlerService,
HttpError), no HTML/logic mixing (styling extracted out of components
entirely, never inline).
ApiClient/ErrorMessageService were initially written as static-only classes;
switched to instance-based singletons (matching ErrorHandlerService's
existing pattern) after Biome's noStaticOnlyClass rule flagged the
static-only shape as an anti-pattern — same "class with visibility
modifiers" outcome, without fighting the linter.
## SCSS + theming
- apps/web/src/styles/_theme.scss — design tokens as CSS custom properties
on :root (colors, spacing, typography), not plain Sass variables — makes
them available at runtime, not just compile time, so a future theme
switch (e.g. dark mode) is "redefine these variables" rather than
rebuilding stylesheets.
- apps/web/src/styles/global.scss replaces the old single index.css:
reset + theme import only, loaded once from main.tsx.
- Per-page/component styles colocated (HomePage.tsx + HomePage.scss);
styles shared by multiple pages within one feature live in that feature's
folder (features/auth/auth-form.scss, used by both Login/SignupPage) —
not duplicated per page, not dumped in the global stylesheet either.
- Component-level .scss files intentionally don't `@use` the theme
partial: they only consume CSS custom properties (global at runtime via
global.scss), not Sass-level symbols, so importing it would do nothing —
documented inline rather than left as a silently-redundant import.
- vite.config.ts opts into Sass's modern compiler API to silence a
legacy-js-api deprecation warning on every build.
## specs/ updates
- New specs/error-handling.md — the ErrorCode/ApiErrorResponse contract,
both services, with a flow diagram.
- New specs/frontend-architecture.md — apps/web folder structure, routing/
auth-guard flow, SCSS/theming conventions.
- specs/batch-cooking-architecture.md links to both (original doc content
otherwise untouched — it's the user's own hand-authored source doc).
## Verification
Full lint/mocha/cucumber/build green. Manually re-verified the whole auth
flow in a real browser against native dev servers (not just the automated
suites): signup, the EMAIL_ALREADY_IN_USE → "Cet email est déjà utilisé"
translation end-to-end (confirmed the raw API response carries the English
dev message + code, and the UI shows the French label), wrong-password
INVALID_CREDENTIALS → its label, and confirmed the theme tokens actually
apply (computed button background-color matches --color-primary, card
max-width matches the token value) rather than trusting the build succeeding.
151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
|
// .github/workflows/ci.yml), and it keeps these specs focused on frontend
|
|
// behavior. Backend behavior itself is covered by apps/api's Mocha/Cucumber
|
|
// suites against a real database.
|
|
|
|
describe("Signup", () => {
|
|
it("creates a profile and lands on the home page", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/signup", {
|
|
statusCode: 201,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
}).as("signup");
|
|
|
|
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");
|
|
cy.url().should("not.include", "/signup");
|
|
cy.contains("Bonjour Alice Martin").should("be.visible");
|
|
});
|
|
|
|
it("shows a client-side validation error without calling the API", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/signup").as("signup");
|
|
|
|
cy.visit("/signup");
|
|
cy.get("#firstName").type("A");
|
|
cy.get("#lastName").type("B");
|
|
cy.get("#email").type("a@example.com");
|
|
cy.get("#password").type("short");
|
|
cy.contains("button", "Créer mon profil").click();
|
|
|
|
cy.contains("8 caractères minimum").should("be.visible");
|
|
cy.get("@signup.all").should("have.length", 0);
|
|
});
|
|
|
|
it("shows the API's error when the email is already taken", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/signup", {
|
|
statusCode: 409,
|
|
body: { code: "EMAIL_ALREADY_IN_USE", message: "Email already in use" },
|
|
}).as("signup");
|
|
|
|
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");
|
|
cy.contains("Cet email est déjà utilisé").should("be.visible");
|
|
});
|
|
});
|
|
|
|
describe("Login", () => {
|
|
it("logs in and lands on the home page", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/login", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
}).as("login");
|
|
|
|
cy.visit("/login");
|
|
cy.get("#email").type("alice@example.com");
|
|
cy.get("#password").type("correct-horse-battery-staple");
|
|
cy.contains("button", "Se connecter").click();
|
|
|
|
cy.wait("@login");
|
|
cy.contains("Bonjour Alice Martin").should("be.visible");
|
|
});
|
|
|
|
it("shows an error on invalid credentials", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
cy.intercept("POST", "**/auth/login", {
|
|
statusCode: 401,
|
|
body: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
|
|
}).as("login");
|
|
|
|
cy.visit("/login");
|
|
cy.get("#email").type("alice@example.com");
|
|
cy.get("#password").type("wrong-password");
|
|
cy.contains("button", "Se connecter").click();
|
|
|
|
cy.wait("@login");
|
|
cy.contains("Email ou mot de passe incorrect").should("be.visible");
|
|
});
|
|
});
|
|
|
|
describe("Already authenticated", () => {
|
|
it("redirects away from /login to the home page", () => {
|
|
cy.intercept("GET", "**/auth/me", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
});
|
|
|
|
cy.visit("/login");
|
|
cy.url().should("not.include", "/login");
|
|
cy.contains("Bonjour Alice Martin").should("be.visible");
|
|
});
|
|
|
|
it("logs out and returns to the login page", () => {
|
|
cy.intercept("GET", "**/auth/me", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
},
|
|
});
|
|
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
|
|
|
cy.visit("/");
|
|
cy.contains("button", "Se déconnecter").click();
|
|
|
|
cy.wait("@logout");
|
|
cy.url().should("include", "/login");
|
|
});
|
|
});
|