From 759fd4cbb1db6a03e1acd87732c92fac3e40348f Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 20:49:20 +0000 Subject: [PATCH] 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 --- coverage/summary.json | 26 +- packages/core-api/package.json | 3 + packages/core-api/src/root.ts | 5 + packages/core-api/src/router.test.ts | 376 ++++++++++++++++++++++++++- pnpm-lock.yaml | 9 + 5 files changed, 412 insertions(+), 7 deletions(-) diff --git a/coverage/summary.json b/coverage/summary.json index ab19ffe..7561218 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,14 +1,14 @@ { - "generatedAt": "2026-05-19T20:36:42.351Z", - "commit": "46e575a", + "generatedAt": "2026-05-19T20:49:04.345Z", + "commit": "b2bfb5b", "repo": { - "statements": 97.17, + "statements": 97.18, "branches": 92.15, "functions": 96.93, - "lines": 97.17, + "lines": 97.18, "counts": { - "lf": 5329, - "lh": 5178, + "lf": 5346, + "lh": 5195, "brf": 1045, "brh": 963, "fnf": 326, @@ -58,6 +58,20 @@ "fnh": 10 } }, + "@repo/core-api": { + "statements": 100, + "branches": 100, + "functions": 100, + "lines": 100, + "counts": { + "lf": 17, + "lh": 17, + "brf": 0, + "brh": 0, + "fnf": 0, + "fnh": 0 + } + }, "@repo/core-consent": { "statements": 99.72, "branches": 94.85, diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 6b91d3a..d91d243 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -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" } } diff --git a/packages/core-api/src/root.ts b/packages/core-api/src/root.ts index 6717caa..a1aff53 100644 --- a/packages/core-api/src/root.ts +++ b/packages/core-api/src/root.ts @@ -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; diff --git a/packages/core-api/src/router.test.ts b/packages/core-api/src/router.test.ts index c8153da..6e76d79 100644 --- a/packages/core-api/src/router.test.ts +++ b/packages/core-api/src/router.test.ts @@ -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; + +function makeIntegrationRouter(dsrBinding: DsrTestBinding) { + return router({ + dsr: createDsrRouter(dsrBinding as unknown as DsrBinding), + consent: consentRouter, + }); +} + +type IntegrationRouter = ReturnType; + +function makeCaller( + testRouter: IntegrationRouter, + userId: string, + consentFactory: ConsentFactory, + roles: string[] = ["user"], +) { + return testRouter.createCaller({ + user: { id: userId, roles } as DsrTrpcUser, + userId, + consentFactory, + } as Record); +} + +function makeUnauthCaller( + testRouter: IntegrationRouter, + consentFactory: ConsentFactory, +) { + return testRouter.createCaller({ + consentFactory, + } as Record); +} + +// --------------------------------------------------------------------------- +// 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; + + 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; + + 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; + + 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; + + 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" }); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13aeaaa..5834f30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,6 +463,12 @@ importers: "@repo/blog": specifier: workspace:* version: link:../blog + "@repo/core-consent": + specifier: workspace:* + version: link:../core-consent + "@repo/core-dsr": + specifier: workspace:* + version: link:../core-dsr "@repo/core-shared": specifier: workspace:* version: link:../core-shared @@ -491,6 +497,9 @@ importers: "@types/node": specifier: ^22.0.0 version: 22.19.17 + "@vitest/coverage-v8": + specifier: ^3.0.0 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) vitest: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)