feat(core-api): compose dsrRouter + consentRouter into appRouter

Add @repo/core-dsr and @repo/core-consent as dependencies and wire
dsrRouter + consentRouter into appRouter via the existing router
composition pattern. Integration tests cover all eight procedures
(dsr.export, dsr.delete, dsr.rectify, dsr.restrict, consent.grant,
consent.withdraw, consent.isGranted, consent.getCategories) with auth
and response-shape assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:49:20 +00:00
parent b2bfb5bfce
commit 759fd4cbb1
5 changed files with 412 additions and 7 deletions

View File

@@ -15,6 +15,8 @@
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/blog": "workspace:*",
"@repo/core-consent": "workspace:*",
"@repo/core-dsr": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/marketing-pages": "workspace:*",
"@repo/media": "workspace:*",
@@ -26,6 +28,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.0.0",
"vitest": "^3.0.0"
}
}

View File

@@ -4,6 +4,8 @@ import { blogRouter } from "@repo/blog/api";
import { marketingPagesRouter } from "@repo/marketing-pages/api";
import { navigationRouter } from "@repo/navigation/api";
import { mediaRouter } from "@repo/media/api";
import { dsrRouter } from "@repo/core-dsr";
import { consentRouter } from "@repo/core-consent";
export const appRouter = router({
auth: authRouter,
@@ -11,6 +13,9 @@ export const appRouter = router({
marketingPages: marketingPagesRouter,
navigation: navigationRouter,
media: mediaRouter,
// gen:routers — optional-core routers composed below
dsr: dsrRouter,
consent: consentRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -1,5 +1,68 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { router } from "@repo/core-shared/trpc/init";
import { appRouter } from "./root";
import { createDsrRouter } from "@repo/core-dsr";
import type { DsrBinding } from "@repo/core-dsr";
import type { DsrTrpcUser } from "@repo/core-dsr";
import { consentRouter } from "@repo/core-consent";
import type { ConsentFactory } from "@repo/core-consent";
import {
RecordingDataExport,
RecordingDataDelete,
RecordingDataRectify,
RecordingProcessingRestriction,
RecordingConsent,
} from "@repo/core-testing/instrumentation";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeDsrBinding() {
return {
dataExport: new RecordingDataExport(),
dataDelete: new RecordingDataDelete(),
dataRectify: new RecordingDataRectify(),
processingRestriction: new RecordingProcessingRestriction(),
};
}
type DsrTestBinding = ReturnType<typeof makeDsrBinding>;
function makeIntegrationRouter(dsrBinding: DsrTestBinding) {
return router({
dsr: createDsrRouter(dsrBinding as unknown as DsrBinding),
consent: consentRouter,
});
}
type IntegrationRouter = ReturnType<typeof makeIntegrationRouter>;
function makeCaller(
testRouter: IntegrationRouter,
userId: string,
consentFactory: ConsentFactory,
roles: string[] = ["user"],
) {
return testRouter.createCaller({
user: { id: userId, roles } as DsrTrpcUser,
userId,
consentFactory,
} as Record<string, unknown>);
}
function makeUnauthCaller(
testRouter: IntegrationRouter,
consentFactory: ConsentFactory,
) {
return testRouter.createCaller({
consentFactory,
} as Record<string, unknown>);
}
// ---------------------------------------------------------------------------
// Structure tests — appRouter composition
// ---------------------------------------------------------------------------
describe("appRouter composition", () => {
it("exposes auth, blog, marketingPages, navigation, media routers", () => {
@@ -17,4 +80,315 @@ describe("appRouter composition", () => {
expect(procedures).toHaveProperty("blog.articleBySlug");
expect(procedures).toHaveProperty("blog.listArticles");
});
it("exposes dsr and consent routers", () => {
const keys = Object.keys(appRouter._def.procedures);
expect(keys.some((k) => k.startsWith("dsr."))).toBe(true);
expect(keys.some((k) => k.startsWith("consent."))).toBe(true);
});
it("dsr router exposes all four procedures", () => {
const procedures = appRouter._def.procedures;
expect(procedures).toHaveProperty("dsr.export");
expect(procedures).toHaveProperty("dsr.delete");
expect(procedures).toHaveProperty("dsr.rectify");
expect(procedures).toHaveProperty("dsr.restrict");
});
it("consent router exposes all four procedures", () => {
const procedures = appRouter._def.procedures;
expect(procedures).toHaveProperty("consent.grant");
expect(procedures).toHaveProperty("consent.withdraw");
expect(procedures).toHaveProperty("consent.isGranted");
expect(procedures).toHaveProperty("consent.getCategories");
});
});
// ---------------------------------------------------------------------------
// Integration tests — dsr procedures
// ---------------------------------------------------------------------------
describe("dsr.export — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "alice", factory);
});
it("resolves with UserDataBundle body for authenticated user", async () => {
const result = await caller.dsr.export({
subjectId: "alice",
format: "json",
});
expect(result.subjectId).toBe("alice");
expect(result.format).toBe("json");
expect(binding.dataExport.calls).toHaveLength(1);
});
it("resolves with json-ld format", async () => {
const result = await caller.dsr.export({
subjectId: "alice",
format: "json-ld",
});
expect(result.format).toBe("json-ld");
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.export({ subjectId: "alice", format: "json" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("dsr.delete — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
});
it("resolves with DeletionCertificate for soft mode (authenticated user)", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const caller = makeCaller(testRouter, "alice", factory);
const result = await caller.dsr.delete({
subjectId: "alice",
mode: "soft",
});
expect(result.subjectId).toBe("alice");
expect(result.mode).toBe("soft");
expect(binding.dataDelete.calls).toHaveLength(1);
});
it("resolves with DeletionCertificate for cascade-hard mode (admin)", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const adminCaller = makeCaller(testRouter, "admin", factory, ["admin"]);
const result = await adminCaller.dsr.delete({
subjectId: "alice",
mode: "cascade-hard",
});
expect(result.mode).toBe("cascade-hard");
});
it("throws FORBIDDEN for cascade-hard when user lacks admin role", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const caller = makeCaller(testRouter, "alice", factory, ["user"]);
await expect(
caller.dsr.delete({ subjectId: "alice", mode: "cascade-hard" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.delete({ subjectId: "alice", mode: "soft" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("dsr.rectify — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "alice", factory);
});
it("resolves with { ok: true } for authenticated user", async () => {
const result = await caller.dsr.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice Updated",
});
expect(result).toEqual({ ok: true });
expect(binding.dataRectify.calls[0]).toMatchObject({
subjectId: "alice",
collection: "users",
field: "name",
});
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.rectify({
subjectId: "alice",
collection: "users",
field: "name",
value: "x",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("dsr.restrict — integration", () => {
let binding: DsrTestBinding;
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "alice", factory);
});
it("resolves with { ok: true } when granting restriction", async () => {
const result = await caller.dsr.restrict({
subjectId: "alice",
granted: true,
});
expect(result).toEqual({ ok: true });
expect(binding.processingRestriction.sets[0]).toMatchObject({
subjectId: "alice",
granted: true,
});
});
it("resolves with { ok: true } when lifting restriction", async () => {
const result = await caller.dsr.restrict({
subjectId: "alice",
granted: false,
});
expect(result).toEqual({ ok: true });
expect(binding.processingRestriction.sets[0]?.granted).toBe(false);
});
it("throws UNAUTHORIZED when unauthenticated", async () => {
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.dsr.restrict({ subjectId: "alice", granted: true }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
// ---------------------------------------------------------------------------
// Integration tests — consent procedures
// ---------------------------------------------------------------------------
describe("consent — integration", () => {
let consent: RecordingConsent;
let caller: ReturnType<typeof makeCaller>;
beforeEach(() => {
const binding = makeDsrBinding();
consent = new RecordingConsent();
const factory: ConsentFactory = async () => consent;
const testRouter = makeIntegrationRouter(binding);
caller = makeCaller(testRouter, "user-1", factory);
});
describe("consent.grant", () => {
it("resolves with { success: true } and records the grant", async () => {
const result = await caller.consent.grant({ category: "analytics" });
expect(result).toEqual({ success: true });
expect(consent.grants).toHaveLength(1);
expect(consent.grants[0]!.category).toBe("analytics");
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.grant({ category: "analytics" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("consent.withdraw", () => {
it("resolves with { success: true } and records the withdrawal", async () => {
await caller.consent.grant({ category: "marketing" });
const result = await caller.consent.withdraw({ category: "marketing" });
expect(result).toEqual({ success: true });
expect(consent.withdrawals).toHaveLength(1);
expect(consent.withdrawals[0]).toBe("marketing");
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.withdraw({ category: "analytics" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("consent.isGranted", () => {
it("resolves with { granted: false } before any grant", async () => {
const result = await caller.consent.isGranted({ category: "analytics" });
expect(result).toEqual({ granted: false });
});
it("resolves with { granted: true } after grant", async () => {
await caller.consent.grant({ category: "analytics" });
const result = await caller.consent.isGranted({ category: "analytics" });
expect(result).toEqual({ granted: true });
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.isGranted({ category: "analytics" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
describe("consent.getCategories", () => {
it("resolves with { categories: [] } initially", async () => {
const result = await caller.consent.getCategories({});
expect(result).toEqual({ categories: [] });
});
it("resolves with all granted categories", async () => {
await caller.consent.grant({ category: "necessary" });
await caller.consent.grant({ category: "analytics" });
const { categories } = await caller.consent.getCategories({});
expect(categories).toHaveLength(2);
const names = categories.map((c) => c.category).sort();
expect(names).toEqual(["analytics", "necessary"]);
});
it("throws UNAUTHORIZED when userId is absent", async () => {
const c = new RecordingConsent();
const factory: ConsentFactory = async () => c;
const testRouter = makeIntegrationRouter(makeDsrBinding());
const unauthCaller = makeUnauthCaller(testRouter, factory);
await expect(
unauthCaller.consent.getCategories({}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
});