Files
HealthLog/src/app/api/user/profile/__tests__/route.test.ts
T
Marc-André Bombeck f435142db5 feat(profile): carry the resolved module map on the /api/user/profile alias
The native client reads /api/user/profile, not /api/auth/me, so the
v1.18.0 module enable/disable map never reached it. Resolve the same
`resolveModuleMap` projection /api/auth/me returns and include it on the
alias response, overlapped with the profile-row read. Extend the route
test to assert the map is present and server-authoritative.
2026-06-16 00:35:59 +02:00

301 lines
9.2 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),
}));
// v1.18.0 — the GET alias resolves the module map. Stub it so these
// tests assert the projection wiring without standing up the gate's
// own DB round-trips (operator availability + assistant flags + cycle).
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn().mockResolvedValue({
sleep: true,
glucose: true,
cycle: false,
coach: true,
}),
}));
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";
import { resolveModuleMap } from "@/lib/modules/gate";
const MODULE_MAP = {
sleep: true,
glucose: true,
cycle: false,
coach: true,
} as const;
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never);
});
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: "testuser",
displayName: "Alex T.",
email: "user@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("testuser");
expect(body.data.displayName).toBe("Alex T.");
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: "testuser",
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");
});
// v1.18.0 — the alias the iOS app reads must carry the resolved module
// map, matching the /api/auth/me projection so the client gets module
// flags from the endpoint it actually fetches.
it("includes the resolved module map", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "testuser",
displayName: null,
email: null,
dateOfBirth: null,
gender: null,
heightCm: null,
locale: null,
timezone: "Europe/Berlin",
moodReminderEnabled: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumberEncrypted: null,
} as never);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
expect(resolveModuleMap).toHaveBeenCalledWith("user-1");
const body = (await res.json()) as {
data: { modules: Record<string, boolean> };
};
expect(body.data.modules).toEqual(MODULE_MAP);
});
});
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: "testuser",
displayName: "Alex T.",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
locale: "de",
} as never);
const res = await PATCH(req({ displayName: "Alex T.", locale: "de" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
displayName: "Alex T.",
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: "testuser",
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: "testuser",
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();
});
});