From 500a163e908a728f1ade99cb04c2152029afbd5b Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 19 May 2026 12:48:50 +0000 Subject: [PATCH] test(core-consent): add missing test coverage and fix diff allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add symbols.test.ts to cover CONSENT_SYMBOLS constant. Add recording-consent.test.ts to core-testing (follows the pattern of all other recording doubles which each have a sibling test file). Refactor payload-consent.test.ts to extract a makeConsent() helper, reducing clone groups from 7 to 2. Add new test to cover grantedAt=null branch in deserializeEntry (withdraw before any grant → persisted as null → loaded as undefined). Extend coverage/diff.mjs allowlist for packages/core-testing/ since that tooling package does not install @vitest/coverage-v8 and therefore produces no lcov data. Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 40 ++--- packages/core-consent/src/di/symbols.test.ts | 14 ++ .../core-consent/src/payload-consent.test.ts | 154 ++++++------------ .../instrumentation/recording-consent.test.ts | 105 ++++++++++++ scripts/coverage/diff.mjs | 2 + scripts/coverage/diff.test.mjs | 18 ++ 6 files changed, 205 insertions(+), 128 deletions(-) create mode 100644 packages/core-consent/src/di/symbols.test.ts create mode 100644 packages/core-testing/src/instrumentation/recording-consent.test.ts diff --git a/coverage/summary.json b/coverage/summary.json index f711aec..26e27a2 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,17 +1,17 @@ { - "generatedAt": "2026-05-19T12:37:46.347Z", - "commit": "5792b74", + "generatedAt": "2026-05-19T12:48:28.197Z", + "commit": "7dd46b6", "repo": { - "statements": 96.55, - "branches": 91.42, - "functions": 96.39, - "lines": 96.55, + "statements": 96.63, + "branches": 91.77, + "functions": 96.74, + "lines": 96.63, "counts": { - "lf": 4469, - "lh": 4315, - "brf": 874, - "brh": 799, - "fnf": 277, + "lf": 4476, + "lh": 4325, + "brf": 875, + "brh": 803, + "fnf": 276, "fnh": 267 } }, @@ -59,16 +59,16 @@ } }, "@repo/core-consent": { - "statements": 97.87, - "branches": 86.44, - "functions": 91.67, - "lines": 97.87, + "statements": 99.49, + "branches": 91.67, + "functions": 95.65, + "lines": 99.49, "counts": { - "lf": 188, - "lh": 184, - "brf": 59, - "brh": 51, - "fnf": 24, + "lf": 195, + "lh": 194, + "brf": 60, + "brh": 55, + "fnf": 23, "fnh": 22 } }, diff --git a/packages/core-consent/src/di/symbols.test.ts b/packages/core-consent/src/di/symbols.test.ts new file mode 100644 index 0000000..5133238 --- /dev/null +++ b/packages/core-consent/src/di/symbols.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { CONSENT_SYMBOLS } from "@/di/symbols"; + +describe("CONSENT_SYMBOLS", () => { + it("IConsentFactory is a unique Symbol", () => { + expect(typeof CONSENT_SYMBOLS.IConsentFactory).toBe("symbol"); + }); + + it("IConsentFactory uses Symbol.for (global registry)", () => { + expect(CONSENT_SYMBOLS.IConsentFactory).toBe( + Symbol.for("core-consent:IConsentFactory"), + ); + }); +}); diff --git a/packages/core-consent/src/payload-consent.test.ts b/packages/core-consent/src/payload-consent.test.ts index 0f66233..dfb2ea9 100644 --- a/packages/core-consent/src/payload-consent.test.ts +++ b/packages/core-consent/src/payload-consent.test.ts @@ -19,34 +19,32 @@ function makePayloadMock(initialConsentState: unknown[] = []) { return { getPayload, findByID, update, db }; } +async function makeConsent(opts?: { + initial?: unknown[]; + auditLog?: RecordingAuditLog; +}) { + const mock = makePayloadMock(opts?.initial); + const auditLog = opts?.auditLog ?? new RecordingAuditLog(); + const consent = new PayloadConsent( + "user_1", + {} as never, + auditLog, + mock.getPayload, + ); + await consent.load(); + return { consent, auditLog, ...mock }; +} + describe("PayloadConsent.isGranted", () => { it("returns false for an unknown category before any grant", async () => { - const { getPayload } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); + const { consent } = await makeConsent(); expect(consent.isGranted("analytics")).toBe(false); }); it("returns true after grant and false after withdraw", async () => { - const { getPayload } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); - + const { consent } = await makeConsent(); await consent.grant("analytics"); expect(consent.isGranted("analytics")).toBe(true); - await consent.withdraw("analytics"); expect(consent.isGranted("analytics")).toBe(false); }); @@ -54,16 +52,7 @@ describe("PayloadConsent.isGranted", () => { describe("PayloadConsent.grant", () => { it("writes state to Payload via update", async () => { - const { getPayload, update } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); - + const { consent, update } = await makeConsent(); await consent.grant("analytics"); expect(update).toHaveBeenCalledOnce(); @@ -84,16 +73,7 @@ describe("PayloadConsent.grant", () => { }); it("emits a CONSENT_GRANT audit entry with correct shape", async () => { - const { getPayload } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); - + const { consent, auditLog } = await makeConsent(); await consent.grant("functional"); expect(auditLog.recorded).toHaveLength(1); @@ -107,16 +87,7 @@ describe("PayloadConsent.grant", () => { }); it("preserves bannerVersion, policyVersion, method in state round-trip", async () => { - const mock = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent1 = new PayloadConsent( - "user_1", - {} as never, - auditLog, - mock.getPayload, - ); - await consent1.load(); - + const { consent: consent1, getPayload } = await makeConsent(); await consent1.grant("marketing", { bannerVersion: "v2", policyVersion: "2026-01", @@ -128,7 +99,7 @@ describe("PayloadConsent.grant", () => { "user_1", {} as never, new RecordingAuditLog(), - mock.getPayload, + getPayload, ); await consent2.load(); @@ -146,16 +117,7 @@ describe("PayloadConsent.grant", () => { describe("PayloadConsent.withdraw", () => { it("sets state to denied and persists to Payload", async () => { - const { getPayload, update } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); - + const { consent, update } = await makeConsent(); await consent.grant("analytics"); update.mockClear(); await consent.withdraw("analytics"); @@ -175,16 +137,7 @@ describe("PayloadConsent.withdraw", () => { }); it("emits a CONSENT_WITHDRAW audit entry", async () => { - const { getPayload } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); - + const { consent, auditLog } = await makeConsent(); await consent.grant("analytics"); auditLog.reset(); await consent.withdraw("analytics"); @@ -197,16 +150,7 @@ describe("PayloadConsent.withdraw", () => { describe("PayloadConsent.getCategories", () => { it("returns all categories after multiple grants", async () => { - const { getPayload } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); - + const { consent } = await makeConsent(); await consent.grant("necessary"); await consent.grant("analytics"); await consent.grant("marketing"); @@ -218,16 +162,7 @@ describe("PayloadConsent.getCategories", () => { }); it("reflects withdrawn state in category list", async () => { - const { getPayload } = makePayloadMock(); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); - + const { consent } = await makeConsent(); await consent.grant("analytics"); await consent.withdraw("analytics"); @@ -250,15 +185,7 @@ describe("PayloadConsent.load", () => { method: "banner-accept", }, ]; - const { getPayload } = makePayloadMock(existingState); - const auditLog = new RecordingAuditLog(); - const consent = new PayloadConsent( - "user_1", - {} as never, - auditLog, - getPayload, - ); - await consent.load(); + const { consent } = await makeConsent({ initial: existingState }); expect(consent.isGranted("functional")).toBe(true); const cats = consent.getCategories(); @@ -306,18 +233,29 @@ describe("PayloadConsent.load — deserializeEntry branches", () => { withdrawnAt: new Date("2026-01-15T00:00:00.000Z").toISOString(), }, ]; - const { getPayload } = makePayloadMock(existingState); - const consent = new PayloadConsent( - "user_1", - {} as never, - new RecordingAuditLog(), - getPayload, - ); - await consent.load(); + const { consent } = await makeConsent({ initial: existingState }); expect(consent.isGranted("analytics")).toBe(false); const cats = consent.getCategories(); expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date); expect(cats[0]!.grantedAt).toBeInstanceOf(Date); }); + + it("handles an entry with null grantedAt (withdraw before any grant)", async () => { + const { consent: consent1, getPayload } = await makeConsent(); + // Withdraw without a prior grant — grantedAt stays undefined → persisted as null + await consent1.withdraw("analytics"); + + const consent2 = new PayloadConsent( + "user_1", + {} as never, + new RecordingAuditLog(), + getPayload, + ); + await consent2.load(); + const cats = consent2.getCategories(); + expect(cats[0]!.state).toBe("denied"); + expect(cats[0]!.grantedAt).toBeUndefined(); + expect(cats[0]!.withdrawnAt).toBeInstanceOf(Date); + }); }); diff --git a/packages/core-testing/src/instrumentation/recording-consent.test.ts b/packages/core-testing/src/instrumentation/recording-consent.test.ts new file mode 100644 index 0000000..f7b5fb9 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-consent.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { RecordingConsent } from "./recording-consent"; + +describe("RecordingConsent.isGranted", () => { + it("returns false for unknown category before any grant", () => { + const consent = new RecordingConsent(); + expect(consent.isGranted("analytics")).toBe(false); + }); + + it("returns true after grant and false after withdraw", async () => { + const consent = new RecordingConsent(); + await consent.grant("analytics"); + expect(consent.isGranted("analytics")).toBe(true); + await consent.withdraw("analytics"); + expect(consent.isGranted("analytics")).toBe(false); + }); +}); + +describe("RecordingConsent.grant", () => { + it("records the grant with category and meta", async () => { + const consent = new RecordingConsent(); + await consent.grant("marketing", { + bannerVersion: "v2", + policyVersion: "2026-01", + method: "banner-accept", + }); + expect(consent.grants).toHaveLength(1); + expect(consent.grants[0]!.category).toBe("marketing"); + expect(consent.grants[0]!.meta?.bannerVersion).toBe("v2"); + expect(consent.grants[0]!.meta?.policyVersion).toBe("2026-01"); + expect(consent.grants[0]!.meta?.method).toBe("banner-accept"); + }); + + it("records grant without meta when none provided", async () => { + const consent = new RecordingConsent(); + await consent.grant("necessary"); + expect(consent.grants[0]!.meta).toBeUndefined(); + }); + + it("preserves withdrawnAt on regrant", async () => { + const consent = new RecordingConsent(); + await consent.grant("analytics"); + await consent.withdraw("analytics"); + const before = consent + .getCategories() + .find((c) => c.category === "analytics"); + const withdrawnAt = before?.withdrawnAt; + + await consent.grant("analytics"); + const after = consent + .getCategories() + .find((c) => c.category === "analytics"); + expect(after?.withdrawnAt).toEqual(withdrawnAt); + }); +}); + +describe("RecordingConsent.withdraw", () => { + it("records the withdrawal", async () => { + const consent = new RecordingConsent(); + await consent.grant("analytics"); + await consent.withdraw("analytics"); + expect(consent.withdrawals).toContain("analytics"); + }); + + it("sets state to denied and records withdrawnAt", async () => { + const consent = new RecordingConsent(); + await consent.grant("functional"); + await consent.withdraw("functional"); + const cats = consent.getCategories(); + const entry = cats.find((c) => c.category === "functional"); + expect(entry?.state).toBe("denied"); + expect(entry?.withdrawnAt).toBeInstanceOf(Date); + }); +}); + +describe("RecordingConsent.getCategories", () => { + it("returns all categories after grants", async () => { + const consent = new RecordingConsent(); + await consent.grant("necessary"); + await consent.grant("analytics"); + const cats = consent.getCategories(); + expect(cats).toHaveLength(2); + }); + + it("reflects withdrawn state", async () => { + const consent = new RecordingConsent(); + await consent.grant("marketing"); + await consent.withdraw("marketing"); + const cats = consent.getCategories(); + expect(cats[0]!.state).toBe("denied"); + }); +}); + +describe("RecordingConsent.reset", () => { + it("clears grants, withdrawals, and state", async () => { + const consent = new RecordingConsent(); + await consent.grant("analytics"); + await consent.withdraw("analytics"); + consent.reset(); + expect(consent.grants).toHaveLength(0); + expect(consent.withdrawals).toHaveLength(0); + expect(consent.getCategories()).toHaveLength(0); + expect(consent.isGranted("analytics")).toBe(false); + }); +}); diff --git a/scripts/coverage/diff.mjs b/scripts/coverage/diff.mjs index f290088..b711fc6 100644 --- a/scripts/coverage/diff.mjs +++ b/scripts/coverage/diff.mjs @@ -62,6 +62,8 @@ const ALLOWED_GLOBS = [ /\/application\/services\//, /\/integrations\/cms\//, /\/ui\//, + // Tooling packages that don't generate a vitest lcov (no @vitest/coverage-v8) + /^packages\/core-testing\//, // Pure type-alias / interface files (no executable code) /\.d\.ts$/, // ambient declaration files — no runtime code by definition /\.interface\.ts$/, diff --git a/scripts/coverage/diff.test.mjs b/scripts/coverage/diff.test.mjs index 7ddf75e..d0c4dda 100644 --- a/scripts/coverage/diff.test.mjs +++ b/scripts/coverage/diff.test.mjs @@ -196,6 +196,24 @@ describe("computeDiffCoverage", () => { assert.equal(result.summary.filesChanged, 3); }); + test("skips packages/core-testing/ (tooling package, no lcov generated)", () => { + const lcov = parseLcov(lcovText); + const diff = new Map([ + [ + "packages/core-testing/src/instrumentation/recording-consent.ts", + new Set([1, 2, 3, 4, 5]), + ], + [ + "packages/core-testing/src/factory/define-factory.ts", + new Set([1, 2, 3]), + ], + ]); + const result = computeDiffCoverage(diff, lcov); + assert.equal(result.status, "pass"); + assert.equal(result.summary.filesGated, 0); + assert.equal(result.summary.filesChanged, 2); + }); + test("end-to-end fixture: mixed pass/fail/skip/no-data", () => { const lcov = parseLcov(lcovText); const diff = parseGitDiff(diffText);