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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
43
packages/core-consent/src/handlers/grant.handler.test.ts
Normal file
43
packages/core-consent/src/handlers/grant.handler.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
packages/core-consent/src/handlers/grant.handler.ts
Normal file
26
packages/core-consent/src/handlers/grant.handler.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
17
packages/core-consent/src/handlers/is-granted.handler.ts
Normal file
17
packages/core-consent/src/handlers/is-granted.handler.ts
Normal 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) };
|
||||
}
|
||||
28
packages/core-consent/src/handlers/withdraw.handler.test.ts
Normal file
28
packages/core-consent/src/handlers/withdraw.handler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
packages/core-consent/src/handlers/withdraw.handler.ts
Normal file
18
packages/core-consent/src/handlers/withdraw.handler.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user