/** * Tests for the Pro-Trial-Reward trigger inside `tryAwardProTrial`. * * Critical path (Ahmed-priority): the 8-line vitest that would have prevented * a 500-cascade. Covers: * - happy path: free + all 6 fields filled + never used → trial awarded * - free + one field missing → no trial * - already pro → no trial * - trial already used → no re-trial * * + zod-validation on the patch endpoint (anonymity / range / enum sanity). */ import { describe, expect, it, vi, beforeEach } from "vitest"; // vi.hoisted ensures the mock-state object exists when vi.mock-factory runs // (vi.mock is hoisted to top-of-file by vitest's transformer). const mocks = vi.hoisted(() => ({ profile: { findUnique: vi.fn(), update: vi.fn(), }, })); vi.mock("../../server/utils/prisma", () => ({ usePrisma: () => ({ profile: mocks.profile, $transaction: async (cb: (tx: { profile: typeof mocks.profile }) => Promise) => cb({ profile: mocks.profile }), }), })); import { tryAwardProTrial, updateDemographics, withdrawDemographics, } from "../../server/db/profile"; const mockProfile = mocks.profile; const FULL_DEMOGRAPHICS = { birthYear: 1989, gender: "male", maritalStatus: "single", employmentStatus: "employed", bundesland: "DE-BY", city: "München", }; beforeEach(() => { vi.clearAllMocks(); }); describe("tryAwardProTrial — happy path", () => { it("awards 7-day trial when free + all fields filled + never used", async () => { mockProfile.findUnique.mockResolvedValueOnce({ plan: "free", proTrialUsedAt: null, ...FULL_DEMOGRAPHICS, }); mockProfile.update.mockResolvedValueOnce({}); const before = Date.now(); const result = await tryAwardProTrial("user-1"); expect(result.trialAwarded).toBe(true); expect(result.expiresAt).toBeInstanceOf(Date); const expiresMs = result.expiresAt!.getTime(); const expected = before + 7 * 24 * 60 * 60 * 1000; expect(expiresMs).toBeGreaterThanOrEqual(expected - 1000); expect(expiresMs).toBeLessThanOrEqual(expected + 5_000); expect(mockProfile.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: expect.objectContaining({ plan: "pro", proTrialSource: "demographics_complete", }), }); }); }); describe("tryAwardProTrial — guard rails", () => { it("does NOT award trial when one demographic field is missing", async () => { mockProfile.findUnique.mockResolvedValueOnce({ plan: "free", proTrialUsedAt: null, ...FULL_DEMOGRAPHICS, city: null, // missing }); const result = await tryAwardProTrial("user-1"); expect(result.trialAwarded).toBe(false); expect(result.expiresAt).toBeNull(); expect(mockProfile.update).not.toHaveBeenCalled(); }); it("does NOT award trial when user is already pro", async () => { mockProfile.findUnique.mockResolvedValueOnce({ plan: "pro", proTrialUsedAt: null, ...FULL_DEMOGRAPHICS, }); const result = await tryAwardProTrial("user-1"); expect(result.trialAwarded).toBe(false); expect(mockProfile.update).not.toHaveBeenCalled(); }); it("does NOT award trial when user is legend", async () => { mockProfile.findUnique.mockResolvedValueOnce({ plan: "legend", proTrialUsedAt: null, ...FULL_DEMOGRAPHICS, }); const result = await tryAwardProTrial("user-1"); expect(result.trialAwarded).toBe(false); expect(mockProfile.update).not.toHaveBeenCalled(); }); it("does NOT award trial when proTrialUsedAt already set (no re-trial)", async () => { mockProfile.findUnique.mockResolvedValueOnce({ plan: "free", proTrialUsedAt: new Date("2026-04-01"), ...FULL_DEMOGRAPHICS, }); const result = await tryAwardProTrial("user-1"); expect(result.trialAwarded).toBe(false); expect(mockProfile.update).not.toHaveBeenCalled(); }); it("does NOT award when birthYear is 0/null (treats as missing)", async () => { mockProfile.findUnique.mockResolvedValueOnce({ plan: "free", proTrialUsedAt: null, ...FULL_DEMOGRAPHICS, birthYear: null, }); const result = await tryAwardProTrial("user-1"); expect(result.trialAwarded).toBe(false); }); }); describe("updateDemographics — first-touch consent stamp", () => { it("sets demographicsConsentAt = NOW on first non-null write", async () => { mockProfile.findUnique.mockResolvedValueOnce({ demographicsConsentAt: null, demographicsWithdrawnAt: null, }); mockProfile.update.mockResolvedValueOnce({}); await updateDemographics("user-1", { birthYear: 1989 }); expect(mockProfile.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: expect.objectContaining({ birthYear: 1989, demographicsConsentAt: expect.any(Date), }), }); }); it("does NOT re-stamp consentAt when already set", async () => { mockProfile.findUnique.mockResolvedValueOnce({ demographicsConsentAt: new Date("2026-01-01"), demographicsWithdrawnAt: null, }); mockProfile.update.mockResolvedValueOnce({}); await updateDemographics("user-1", { profession: "Pflege" }); const call = mockProfile.update.mock.calls[0]?.[0] as { data: Record; }; expect(call.data).not.toHaveProperty("demographicsConsentAt"); }); it("clears demographicsWithdrawnAt when re-granted (re-fill after withdrawal)", async () => { mockProfile.findUnique.mockResolvedValueOnce({ demographicsConsentAt: new Date("2026-01-01"), demographicsWithdrawnAt: new Date("2026-03-01"), }); mockProfile.update.mockResolvedValueOnce({}); await updateDemographics("user-1", { birthYear: 1989 }); expect(mockProfile.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: expect.objectContaining({ demographicsWithdrawnAt: null, }), }); }); it("does NOT stamp consent when patch contains only nulls (clearing)", async () => { mockProfile.findUnique.mockResolvedValueOnce({ demographicsConsentAt: null, demographicsWithdrawnAt: null, }); mockProfile.update.mockResolvedValueOnce({}); await updateDemographics("user-1", { city: null }); const call = mockProfile.update.mock.calls[0]?.[0] as { data: Record; }; expect(call.data).not.toHaveProperty("demographicsConsentAt"); }); }); describe("withdrawDemographics", () => { it("nulls all demographic fields + sets withdrawnAt + keeps consentAt", async () => { mockProfile.update.mockResolvedValueOnce({}); await withdrawDemographics("user-1"); expect(mockProfile.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: expect.objectContaining({ birthYear: null, gender: null, maritalStatus: null, profession: null, // legacy field also cleared employmentStatus: null, shiftWork: null, industry: null, jobTenure: null, bundesland: null, city: null, demographicsWithdrawnAt: expect.any(Date), }), }); const call = mockProfile.update.mock.calls[0]?.[0] as { data: Record; }; // consent stamp must NOT be wiped (audit trail) expect(call.data).not.toHaveProperty("demographicsConsentAt"); }); });