mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The clock format followed the UI language with no way out — an English interface forced AM/PM everywhere. A new profile preference (automatic, 24-hour, 12-hour) feeds every formatter through the shared locale context, persists on the user row and rides the profile endpoints. The insight pill list in settings gains the same eye toggle the overview tiles have, so ordering and visibility live in one place; the page-side 'manage detail pages' block folds into a link there. Includes the locale bundles for both features and the regenerated API contract.
250 lines
7.6 KiB
TypeScript
250 lines
7.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
user: { findUnique: vi.fn(), update: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
|
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: () => null })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
import { GET, PATCH } from "../route";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
|
|
const SESSION_OK = {
|
|
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
|
user: { id: "user-1", username: "marc", role: "USER" as const },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
|
function makeGetReq(): NextRequest {
|
|
return new NextRequest("http://localhost/api/user/profile");
|
|
}
|
|
|
|
describe("GET /api/user/profile", () => {
|
|
it("returns 401 when unauthenticated", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
const res = await callGet(makeGetReq());
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns flattened iOS-style fields", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
username: "marc",
|
|
displayName: "Marc B.",
|
|
email: "marc@example.com",
|
|
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
|
|
gender: "MALE",
|
|
heightCm: 180,
|
|
locale: "de",
|
|
timezone: "Europe/Berlin",
|
|
} as never);
|
|
|
|
const res = await callGet(makeGetReq());
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
username: string;
|
|
displayName: string | null;
|
|
heightCm: number;
|
|
locale: string | null;
|
|
};
|
|
};
|
|
expect(body.data.username).toBe("marc");
|
|
expect(body.data.displayName).toBe("Marc B.");
|
|
expect(body.data.heightCm).toBe(180);
|
|
expect(body.data.locale).toBe("de");
|
|
});
|
|
|
|
it("echoes the insurer IK number on GET", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
username: "marc",
|
|
displayName: null,
|
|
email: null,
|
|
dateOfBirth: null,
|
|
gender: null,
|
|
heightCm: null,
|
|
locale: null,
|
|
timezone: "Europe/Berlin",
|
|
moodReminderEnabled: false,
|
|
fullName: null,
|
|
insurerName: "Example Insurer",
|
|
insurerIkNumber: "101234567",
|
|
insuranceNumberEncrypted: null,
|
|
} as never);
|
|
|
|
const res = await callGet(makeGetReq());
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { insurerIkNumber: string | null; insurerName: string | null };
|
|
};
|
|
expect(body.data.insurerIkNumber).toBe("101234567");
|
|
expect(body.data.insurerName).toBe("Example Insurer");
|
|
});
|
|
});
|
|
|
|
describe("PATCH /api/user/profile", () => {
|
|
function req(body: unknown): NextRequest {
|
|
return new NextRequest("http://localhost/api/user/profile", {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
it("returns 401 when unauthenticated", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
const res = await PATCH(req({ displayName: "Test" }));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns 422 for invalid heightCm", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await PATCH(req({ heightCm: 9999 }));
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("persists displayName + locale via shared helper", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.user.update).mockResolvedValue({
|
|
id: "user-1",
|
|
username: "marc",
|
|
displayName: "Marc B.",
|
|
email: null,
|
|
role: "USER",
|
|
heightCm: null,
|
|
dateOfBirth: null,
|
|
gender: null,
|
|
timezone: "Europe/Berlin",
|
|
locale: "de",
|
|
} as never);
|
|
|
|
const res = await PATCH(req({ displayName: "Marc B.", locale: "de" }));
|
|
expect(res.status).toBe(200);
|
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: "user-1" },
|
|
data: expect.objectContaining({
|
|
displayName: "Marc B.",
|
|
locale: "de",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts a valid IKNR and echoes it back", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.user.update).mockResolvedValue({
|
|
id: "user-1",
|
|
username: "marc",
|
|
displayName: null,
|
|
email: null,
|
|
role: "USER",
|
|
heightCm: null,
|
|
dateOfBirth: null,
|
|
gender: null,
|
|
timezone: "Europe/Berlin",
|
|
locale: null,
|
|
moodReminderEnabled: false,
|
|
fullName: null,
|
|
insurerName: null,
|
|
insurerIkNumber: "101234567",
|
|
insuranceNumberEncrypted: null,
|
|
} as never);
|
|
|
|
const res = await PATCH(req({ insurerIkNumber: "101234567" }));
|
|
expect(res.status).toBe(200);
|
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ insurerIkNumber: "101234567" }),
|
|
}),
|
|
);
|
|
const body = (await res.json()) as {
|
|
data: { insurerIkNumber: string | null };
|
|
};
|
|
expect(body.data.insurerIkNumber).toBe("101234567");
|
|
});
|
|
|
|
it("returns 422 for a malformed IKNR", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await PATCH(req({ insurerIkNumber: "abc" }));
|
|
expect(res.status).toBe(422);
|
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// v1.15.20 — hour-cycle display preference rides the shared profile
|
|
// update path: a valid enum value persists field-by-field and echoes
|
|
// back; anything outside AUTO/H12/H24 is rejected by the Zod schema.
|
|
it("persists a valid timeFormat and echoes it back", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.user.update).mockResolvedValue({
|
|
id: "user-1",
|
|
username: "marc",
|
|
displayName: null,
|
|
email: null,
|
|
role: "USER",
|
|
heightCm: null,
|
|
dateOfBirth: null,
|
|
gender: null,
|
|
timezone: "Europe/Berlin",
|
|
locale: null,
|
|
timeFormat: "H24",
|
|
moodReminderEnabled: false,
|
|
fullName: null,
|
|
insurerName: null,
|
|
insurerIkNumber: null,
|
|
insuranceNumberEncrypted: null,
|
|
} as never);
|
|
|
|
const res = await PATCH(req({ timeFormat: "H24" }));
|
|
expect(res.status).toBe(200);
|
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: "user-1" },
|
|
data: expect.objectContaining({ timeFormat: "H24" }),
|
|
}),
|
|
);
|
|
const body = (await res.json()) as {
|
|
data: { timeFormat: string };
|
|
};
|
|
expect(body.data.timeFormat).toBe("H24");
|
|
});
|
|
|
|
it("returns 422 for an invalid timeFormat", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await PATCH(req({ timeFormat: "12h" }));
|
|
expect(res.status).toBe(422);
|
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|