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 ──────────────────────────────────────────────────────
|
// ─── Pro-Trial-Reward ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const PRO_TRIAL_DAYS = 7;
|
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