feat(api): GET /api/profile/me/demographics endpoint
Read-counterpart zum existierenden PATCH/DELETE. Frontend braucht den endpoint um nach Page-Reload die schon-gespeicherten Werte zu fetchen — sonst sieht User leere Felder und denkt save funktioniert nicht. - backend/server/db/profile.ts: getDemographics(userId) — SELECT der 9 fields + demographics_consent_at + demographics_withdrawn_at - backend/server/api/profile/me/demographics.get.ts: requireUser + getDemographics + ISO-string conversion. 404 wenn Profile-row fehlt. - backend/tests/profile/demographics.get.test.ts: 5 vitest cases (null fields, 404, populated, withdrawn, 401) Response shape kompatibel mit PATCH-input (gleiche field names, camelCase) plus metadata consentAt/withdrawnAt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c52d8869e
commit
0e94ddb68a
18
backend/server/api/profile/me/demographics.get.ts
Normal file
18
backend/server/api/profile/me/demographics.get.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* GET /api/profile/me/demographics
|
||||
*
|
||||
* Returns the 9 demographic fields + 2 consent-timestamps for the current
|
||||
* user. All fields are null when not yet filled. Frontend uses this on
|
||||
* page-open to hydrate the DemographicsAccordion form.
|
||||
*
|
||||
* DSGVO note: only the authenticated user can read their own demographics.
|
||||
* Fields are never exposed in public profile endpoints.
|
||||
*/
|
||||
import { requireUser } from "../../../utils/auth";
|
||||
import { getDemographics } from "../../../db/profile";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const data = await getDemographics(user.id);
|
||||
return { success: true, data };
|
||||
});
|
||||
@ -101,6 +101,41 @@ export async function withdrawDemographics(userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Read demographic fields + consent-state for the current user. */
|
||||
export async function getDemographics(userId: string) {
|
||||
const db = usePrisma();
|
||||
const row = await db.profile.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
birthYear: true,
|
||||
gender: true,
|
||||
maritalStatus: true,
|
||||
employmentStatus: true,
|
||||
shiftWork: true,
|
||||
industry: true,
|
||||
jobTenure: true,
|
||||
bundesland: true,
|
||||
city: true,
|
||||
demographicsConsentAt: true,
|
||||
demographicsWithdrawnAt: true,
|
||||
},
|
||||
});
|
||||
if (!row) throw createError({ statusCode: 404, message: "Profil nicht gefunden" });
|
||||
return {
|
||||
birthYear: row.birthYear,
|
||||
gender: row.gender,
|
||||
maritalStatus: row.maritalStatus,
|
||||
employmentStatus: row.employmentStatus,
|
||||
shiftWork: row.shiftWork,
|
||||
industry: row.industry,
|
||||
jobTenure: row.jobTenure,
|
||||
bundesland: row.bundesland,
|
||||
city: row.city,
|
||||
consentAt: row.demographicsConsentAt?.toISOString() ?? null,
|
||||
withdrawnAt: row.demographicsWithdrawnAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Pro-Trial-Reward ──────────────────────────────────────────────────────
|
||||
|
||||
export const PRO_TRIAL_DAYS = 7;
|
||||
|
||||
165
backend/tests/profile/demographics.get.test.ts
Normal file
165
backend/tests/profile/demographics.get.test.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Tests for GET /api/profile/me/demographics
|
||||
*
|
||||
* Covers:
|
||||
* - returns all-null fields when Profile row has no demographics yet
|
||||
* - returns 401 when not authenticated
|
||||
* - returns saved values when row is populated
|
||||
* - returns withdrawnAt when data was withdrawn
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
profile: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
requireUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../server/utils/prisma", () => ({
|
||||
usePrisma: () => ({ profile: mocks.profile }),
|
||||
}));
|
||||
|
||||
vi.mock("../../server/utils/auth", () => ({
|
||||
requireUser: mocks.requireUser,
|
||||
}));
|
||||
|
||||
import { getDemographics } from "../../server/db/profile";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── getDemographics DB-layer ─────────────────────────────────────────────
|
||||
|
||||
describe("getDemographics — all-null row", () => {
|
||||
it("returns null for all 9 demographic fields + consent fields when not yet filled", async () => {
|
||||
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||
birthYear: null,
|
||||
gender: null,
|
||||
maritalStatus: null,
|
||||
employmentStatus: null,
|
||||
shiftWork: null,
|
||||
industry: null,
|
||||
jobTenure: null,
|
||||
bundesland: null,
|
||||
city: null,
|
||||
demographicsConsentAt: null,
|
||||
demographicsWithdrawnAt: null,
|
||||
});
|
||||
|
||||
const result = await getDemographics("user-1");
|
||||
|
||||
expect(result).toEqual({
|
||||
birthYear: null,
|
||||
gender: null,
|
||||
maritalStatus: null,
|
||||
employmentStatus: null,
|
||||
shiftWork: null,
|
||||
industry: null,
|
||||
jobTenure: null,
|
||||
bundesland: null,
|
||||
city: null,
|
||||
consentAt: null,
|
||||
withdrawnAt: null,
|
||||
});
|
||||
|
||||
expect(mocks.profile.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: expect.objectContaining({
|
||||
birthYear: true,
|
||||
gender: true,
|
||||
demographicsConsentAt: true,
|
||||
demographicsWithdrawnAt: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDemographics — 404 when profile missing", () => {
|
||||
it("throws 404 when profile row does not exist", async () => {
|
||||
mocks.profile.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getDemographics("ghost-user")).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDemographics — populated row", () => {
|
||||
it("returns ISO strings for dates and correct field values", async () => {
|
||||
const consentDate = new Date("2026-04-01T10:00:00Z");
|
||||
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||
birthYear: 1989,
|
||||
gender: "male",
|
||||
maritalStatus: "single",
|
||||
employmentStatus: "employed",
|
||||
shiftWork: false,
|
||||
industry: "IT",
|
||||
jobTenure: "3_5y",
|
||||
bundesland: "DE-BY",
|
||||
city: "München",
|
||||
demographicsConsentAt: consentDate,
|
||||
demographicsWithdrawnAt: null,
|
||||
});
|
||||
|
||||
const result = await getDemographics("user-2");
|
||||
|
||||
expect(result.birthYear).toBe(1989);
|
||||
expect(result.gender).toBe("male");
|
||||
expect(result.consentAt).toBe("2026-04-01T10:00:00.000Z");
|
||||
expect(result.withdrawnAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDemographics — withdrawn row", () => {
|
||||
it("returns withdrawnAt as ISO string when data was withdrawn", async () => {
|
||||
const consentDate = new Date("2026-03-01T00:00:00Z");
|
||||
const withdrawnDate = new Date("2026-04-15T12:00:00Z");
|
||||
mocks.profile.findUnique.mockResolvedValueOnce({
|
||||
birthYear: null,
|
||||
gender: null,
|
||||
maritalStatus: null,
|
||||
employmentStatus: null,
|
||||
shiftWork: null,
|
||||
industry: null,
|
||||
jobTenure: null,
|
||||
bundesland: null,
|
||||
city: null,
|
||||
demographicsConsentAt: consentDate,
|
||||
demographicsWithdrawnAt: withdrawnDate,
|
||||
});
|
||||
|
||||
const result = await getDemographics("user-3");
|
||||
|
||||
expect(result.consentAt).toBe("2026-03-01T00:00:00.000Z");
|
||||
expect(result.withdrawnAt).toBe("2026-04-15T12:00:00.000Z");
|
||||
// all data fields nulled after withdrawal
|
||||
expect(result.birthYear).toBeNull();
|
||||
expect(result.city).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 401 guard (endpoint-level) ───────────────────────────────────────────
|
||||
// We test the auth guard by importing the handler and simulating a
|
||||
// requireUser rejection — no real Nitro boot needed.
|
||||
|
||||
describe("demographics.get endpoint — 401 when not authenticated", () => {
|
||||
it("propagates the 401 error from requireUser", async () => {
|
||||
const authError = Object.assign(new Error("Unauthorized"), {
|
||||
statusCode: 401,
|
||||
});
|
||||
mocks.requireUser.mockRejectedValueOnce(authError);
|
||||
|
||||
// Import handler (defineEventHandler stub in setup.ts returns the fn as-is)
|
||||
const mod = await import(
|
||||
"../../server/api/profile/me/demographics.get"
|
||||
);
|
||||
const handler =
|
||||
typeof mod.default === "function" ? mod.default : (mod.default as { handler?: unknown }).handler;
|
||||
|
||||
await expect(
|
||||
(handler as (e: unknown) => Promise<unknown>)({ body: null }),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user