API: module preferences — GET/PATCH /preferences (step 2/4)
- getPreferences: SYSTEM par défaut si aucune ligne (même logique que dietId/allergies : absent = valeur par défaut, pas une omission) - updatePreferences: upsert (crée la ligne au premier PATCH) - Tests Mocha + Cucumber : 401, valeur invalide, défaut, création à la volée, cloisonnement entre profils
This commit is contained in:
parent
e3811dc280
commit
3acde696f5
7 changed files with 197 additions and 1 deletions
23
apps/api/features/preferences.feature
Normal file
23
apps/api/features/preferences.feature
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
Feature: User preferences (theme)
|
||||
As a signed-in user
|
||||
I want to choose a light, dark, or system theme
|
||||
So that the app matches how I like to read it
|
||||
|
||||
Scenario: A visitor without a session cannot read preferences
|
||||
When I send a GET request to "/preferences"
|
||||
Then the response status should be 401
|
||||
And the response error code should be "NOT_AUTHENTICATED"
|
||||
|
||||
Scenario: A signed-in user with no preferences yet defaults to SYSTEM
|
||||
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 request my preferences
|
||||
Then the response status should be 200
|
||||
And my theme preference should be "SYSTEM"
|
||||
|
||||
Scenario: A signed-in user sets their theme preference
|
||||
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 set my theme preference to "DARK"
|
||||
Then the response status should be 200
|
||||
And my theme preference should be "DARK"
|
||||
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
15
apps/api/features/step-definitions/preferences.steps.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { Then, When } from "@cucumber/cucumber";
|
||||
import type { CustomWorld } from "../support/world.js";
|
||||
|
||||
When("I request my preferences", async function (this: CustomWorld) {
|
||||
this.response = await this.agent.get("/preferences");
|
||||
});
|
||||
|
||||
When("I set my theme preference to {string}", async function (this: CustomWorld, theme: string) {
|
||||
this.response = await this.agent.patch("/preferences").send({ theme });
|
||||
});
|
||||
|
||||
Then("my theme preference should be {string}", function (this: CustomWorld, theme: string) {
|
||||
assert.equal(this.response.body.theme, theme);
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import { env } from "./config/env.js";
|
|||
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||
import { houseRouter } from "./modules/house/house.routes.js";
|
||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||
import { referenceRouter } from "./modules/reference/reference.routes.js";
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ export function createServer(): ExpressServer {
|
|||
server.mountRouter("/auth", authRouter);
|
||||
server.mountRouter("/house", houseRouter);
|
||||
server.mountRouter("/planning", planningRouter);
|
||||
server.mountRouter("/preferences", preferencesRouter);
|
||||
server.mountRouter("/profile", profileRouter);
|
||||
server.mountRouter("/reference", referenceRouter);
|
||||
|
||||
|
|
|
|||
27
apps/api/src/modules/preferences/preferences.routes.ts
Normal file
27
apps/api/src/modules/preferences/preferences.routes.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { updatePreferencesSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getPreferences, updatePreferences } from "./preferences.service.js";
|
||||
|
||||
/** Router mounted at `/preferences` in app.ts. Every route requires a session — this is the authenticated user's own preferences. */
|
||||
export const preferencesRouter = Router();
|
||||
|
||||
preferencesRouter.get(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||
const preferences = await getPreferences(res.locals.userProfile.id);
|
||||
res.status(200).json(preferences);
|
||||
}),
|
||||
);
|
||||
|
||||
preferencesRouter.patch(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = updatePreferencesSchema.parse(req.body);
|
||||
const preferences = await updatePreferences(res.locals.userProfile.id, input.theme);
|
||||
res.status(200).json(preferences);
|
||||
}),
|
||||
);
|
||||
31
apps/api/src/modules/preferences/preferences.service.ts
Normal file
31
apps/api/src/modules/preferences/preferences.service.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { PreferencesView, ThemePreference } from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/**
|
||||
* A profile's personalization preferences. `SYSTEM` (the schema default)
|
||||
* is returned both when a row already says so *and* when there's no row
|
||||
* yet at all — same "absent means the default" philosophy as
|
||||
* `dietId`/allergies elsewhere in `profile.service.ts`, no row is created
|
||||
* just to read it.
|
||||
*/
|
||||
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
|
||||
return { theme: preferences?.theme ?? "SYSTEM" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a profile's theme preference, creating its preferences row on first
|
||||
* write (an `upsert` rather than requiring a separate "create" step — a
|
||||
* profile never needs to explicitly initialize this row before using it).
|
||||
*/
|
||||
export async function updatePreferences(
|
||||
userProfileId: number,
|
||||
theme: ThemePreference,
|
||||
): Promise<PreferencesView> {
|
||||
const preferences = await prisma.userPreference.upsert({
|
||||
where: { userProfileId },
|
||||
create: { userProfileId, theme },
|
||||
update: { theme },
|
||||
});
|
||||
return { theme: preferences.theme };
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
|||
export async function resetDatabase() {
|
||||
await prisma.$executeRawUnsafe(`
|
||||
TRUNCATE TABLE
|
||||
"user_profile_allergy", "allergy", "category",
|
||||
"user_profile_allergy", "user_preference", "allergy", "category",
|
||||
"planning_item", "planning",
|
||||
"recipe_ingredient", "step", "tech_step_mapping", "tech_step",
|
||||
"recipe", "ingredients", "sources",
|
||||
|
|
|
|||
98
apps/api/test/preferences.test.ts
Normal file
98
apps/api/test/preferences.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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";
|
||||
|
||||
function buildSignupPayload(): SignupInput {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||
password: faker.internet.password({ length: 16 }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Preferences", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("GET /preferences", () => {
|
||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/preferences");
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("defaults to SYSTEM when the profile has no preferences row yet", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/preferences");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.deep.equal({ theme: "SYSTEM" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /preferences", () => {
|
||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).patch("/preferences").send({ theme: "DARK" });
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("rejects an unknown theme with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.patch("/preferences").send({ theme: "PURPLE" });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("creates the preferences row on first write, and reuses it on later reads/writes", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const patchRes = await agent.patch("/preferences").send({ theme: "DARK" });
|
||||
expect(patchRes.status).to.equal(200);
|
||||
expect(patchRes.body).to.deep.equal({ theme: "DARK" });
|
||||
|
||||
const getRes = await agent.get("/preferences");
|
||||
expect(getRes.body).to.deep.equal({ theme: "DARK" });
|
||||
|
||||
const secondPatchRes = await agent.patch("/preferences").send({ theme: "LIGHT" });
|
||||
expect(secondPatchRes.body).to.deep.equal({ theme: "LIGHT" });
|
||||
|
||||
const secondGetRes = await agent.get("/preferences");
|
||||
expect(secondGetRes.body).to.deep.equal({ theme: "LIGHT" });
|
||||
});
|
||||
|
||||
it("scopes preferences to the caller's own profile", async () => {
|
||||
const aliceAgent = request.agent(app);
|
||||
await aliceAgent.post("/auth/signup").send(buildSignupPayload());
|
||||
await aliceAgent.patch("/preferences").send({ theme: "DARK" });
|
||||
|
||||
const bobAgent = request.agent(app);
|
||||
await bobAgent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await bobAgent.get("/preferences");
|
||||
expect(res.body).to.deep.equal({ theme: "SYSTEM" });
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue