diff --git a/apps/api/features/preferences.feature b/apps/api/features/preferences.feature new file mode 100644 index 0000000..17cd1b9 --- /dev/null +++ b/apps/api/features/preferences.feature @@ -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" diff --git a/apps/api/features/step-definitions/preferences.steps.ts b/apps/api/features/step-definitions/preferences.steps.ts new file mode 100644 index 0000000..3062682 --- /dev/null +++ b/apps/api/features/step-definitions/preferences.steps.ts @@ -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); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c4f8727..b86dc5d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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); diff --git a/apps/api/src/modules/preferences/preferences.routes.ts b/apps/api/src/modules/preferences/preferences.routes.ts new file mode 100644 index 0000000..67b0cc9 --- /dev/null +++ b/apps/api/src/modules/preferences/preferences.routes.ts @@ -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(async (_req, res) => { + const preferences = await getPreferences(res.locals.userProfile.id); + res.status(200).json(preferences); + }), +); + +preferencesRouter.patch( + "/", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = updatePreferencesSchema.parse(req.body); + const preferences = await updatePreferences(res.locals.userProfile.id, input.theme); + res.status(200).json(preferences); + }), +); diff --git a/apps/api/src/modules/preferences/preferences.service.ts b/apps/api/src/modules/preferences/preferences.service.ts new file mode 100644 index 0000000..6e6d2a0 --- /dev/null +++ b/apps/api/src/modules/preferences/preferences.service.ts @@ -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 { + 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 { + const preferences = await prisma.userPreference.upsert({ + where: { userProfileId }, + create: { userProfileId, theme }, + update: { theme }, + }); + return { theme: preferences.theme }; +} diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 9b5b54e..c758f49 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -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", diff --git a/apps/api/test/preferences.test.ts b/apps/api/test/preferences.test.ts new file mode 100644 index 0000000..959da81 --- /dev/null +++ b/apps/api/test/preferences.test.ts @@ -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" }); + }); + }); +});