feat(core-consent): add handlers and consentRouter tRPC router

Protocol-agnostic handlers (grant, withdraw, isGranted, getCategories)
in core-consent/handlers/ call IConsent methods and return typed results.

consentRouter uses a consent-specific tRPC context (userId + consentFactory)
so each procedure can resolve the per-user IConsent instance at call time.
Auth middleware guards all four procedures and maps UnauthenticatedError →
UNAUTHORIZED via defineErrorMiddleware from core-shared (no local duplicate).

76 tests passing; new handler and router code at 100% branch coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:22:33 +00:00
parent ae4e0f2680
commit e53f35a0c5
17 changed files with 518 additions and 24 deletions

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { getCategoriesHandler } from "@/handlers/get-categories.handler";
describe("getCategoriesHandler", () => {
let consent: RecordingConsent;
beforeEach(() => {
consent = new RecordingConsent();
});
it("returns { categories: [] } when no consent has been recorded", () => {
expect(getCategoriesHandler(consent)).toEqual({ categories: [] });
});
it("returns granted categories after grant", async () => {
await consent.grant("analytics");
const { categories } = getCategoriesHandler(consent);
expect(categories).toHaveLength(1);
expect(categories[0]!.category).toBe("analytics");
expect(categories[0]!.state).toBe("granted");
});
it("reflects withdrawn state after withdraw", async () => {
await consent.grant("marketing");
await consent.withdraw("marketing");
const { categories } = getCategoriesHandler(consent);
expect(categories).toHaveLength(1);
expect(categories[0]!.state).toBe("denied");
});
it("returns all categories when multiple are recorded", async () => {
await consent.grant("necessary");
await consent.grant("analytics");
await consent.grant("marketing");
const { categories } = getCategoriesHandler(consent);
expect(categories).toHaveLength(3);
const catNames = categories.map((c) => c.category).sort();
expect(catNames).toEqual(["analytics", "marketing", "necessary"]);
});
});

View File

@@ -0,0 +1,8 @@
import type { IConsent } from "../consent.interface";
import type { UserConsentState } from "../consent-types";
export function getCategoriesHandler(consent: IConsent): {
categories: UserConsentState[];
} {
return { categories: consent.getCategories() };
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { grantHandler } from "@/handlers/grant.handler";
describe("grantHandler", () => {
let consent: RecordingConsent;
beforeEach(() => {
consent = new RecordingConsent();
});
it("returns { success: true }", async () => {
const result = await grantHandler(consent, { category: "analytics" });
expect(result).toEqual({ success: true });
});
it("calls consent.grant with the given category", async () => {
await grantHandler(consent, { category: "marketing" });
expect(consent.grants).toHaveLength(1);
expect(consent.grants[0]!.category).toBe("marketing");
});
it("forwards meta to consent.grant", async () => {
await grantHandler(consent, {
category: "functional",
meta: {
bannerVersion: "v2",
policyVersion: "2026-01",
method: "banner-accept",
},
});
expect(consent.grants[0]!.meta).toEqual({
bannerVersion: "v2",
policyVersion: "2026-01",
method: "banner-accept",
});
});
it("grants without meta when omitted", async () => {
await grantHandler(consent, { category: "necessary" });
expect(consent.grants[0]!.meta).toBeUndefined();
});
});

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import type { IConsent } from "../consent.interface";
export const grantHandlerInputSchema = z
.object({
category: z.string().min(1),
meta: z
.object({
bannerVersion: z.string().optional(),
policyVersion: z.string().optional(),
method: z.string().optional(),
})
.strict()
.optional(),
})
.strict();
export type GrantHandlerInput = z.infer<typeof grantHandlerInputSchema>;
export async function grantHandler(
consent: IConsent,
input: GrantHandlerInput,
): Promise<{ success: true }> {
await consent.grant(input.category, input.meta);
return { success: true };
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { isGrantedHandler } from "@/handlers/is-granted.handler";
describe("isGrantedHandler", () => {
let consent: RecordingConsent;
beforeEach(() => {
consent = new RecordingConsent();
});
it("returns { granted: false } before any grant", () => {
expect(isGrantedHandler(consent, { category: "analytics" })).toEqual({
granted: false,
});
});
it("returns { granted: true } after grant", async () => {
await consent.grant("analytics");
expect(isGrantedHandler(consent, { category: "analytics" })).toEqual({
granted: true,
});
});
it("returns { granted: false } after withdraw", async () => {
await consent.grant("analytics");
await consent.withdraw("analytics");
expect(isGrantedHandler(consent, { category: "analytics" })).toEqual({
granted: false,
});
});
});

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import type { IConsent } from "../consent.interface";
export const isGrantedHandlerInputSchema = z
.object({
category: z.string().min(1),
})
.strict();
export type IsGrantedHandlerInput = z.infer<typeof isGrantedHandlerInputSchema>;
export function isGrantedHandler(
consent: IConsent,
input: IsGrantedHandlerInput,
): { granted: boolean } {
return { granted: consent.isGranted(input.category) };
}

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, beforeEach } from "vitest";
import { RecordingConsent } from "@repo/core-testing/instrumentation";
import { withdrawHandler } from "@/handlers/withdraw.handler";
describe("withdrawHandler", () => {
let consent: RecordingConsent;
beforeEach(async () => {
consent = new RecordingConsent();
await consent.grant("analytics");
});
it("returns { success: true }", async () => {
const result = await withdrawHandler(consent, { category: "analytics" });
expect(result).toEqual({ success: true });
});
it("calls consent.withdraw with the given category", async () => {
await withdrawHandler(consent, { category: "analytics" });
expect(consent.withdrawals).toHaveLength(1);
expect(consent.withdrawals[0]).toBe("analytics");
});
it("category is no longer granted after withdraw", async () => {
await withdrawHandler(consent, { category: "analytics" });
expect(consent.isGranted("analytics")).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
import { z } from "zod";
import type { IConsent } from "../consent.interface";
export const withdrawHandlerInputSchema = z
.object({
category: z.string().min(1),
})
.strict();
export type WithdrawHandlerInput = z.infer<typeof withdrawHandlerInputSchema>;
export async function withdrawHandler(
consent: IConsent,
input: WithdrawHandlerInput,
): Promise<{ success: true }> {
await consent.withdraw(input.category);
return { success: true };
}