Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
# @repo/core-testing
Shared testing utilities. Tag: `tooling`. May be depended on by any package as a devDependency.
## Subpath exports
- `@repo/core-testing/factory``defineFactory<T>(builder)` for test data factories
- `@repo/core-testing/contract``defineContractSuite<T>(name, suite)` for cross-impl contract tests
- `@repo/core-testing/react``renderWithProviders`, `createMockTrpcClient`
- `renderWithProviders` does NOT include a tRPC provider. Consumers needing tRPC should wire their own TRPCProvider (from their app's tRPC client setup) and use `createMockTrpcClient` as the client. This constraint exists because tooling packages cannot import `AppRouter` from `@repo/core-api`.
- `@repo/core-testing/payload``stubPayloadConfig`, `mockPayloadModule`
- `@repo/core-testing/setup/jsdom` — vitest setupFile (jest-dom + cleanup)
- `@repo/core-testing/setup/node` — vitest setupFile (no-op placeholder)
## Adding a factory
```typescript
import { defineFactory } from "@repo/core-testing/factory";
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
id: `article-${sequence}`,
title: `Article ${sequence}`,
// stable defaults — overrides drive variation
}));
```
## Using createMockTrpcClient
For component tests that consume tRPC procedures, mock the responses by procedure path (dot-separated):
```typescript
import { createMockTrpcClient } from "@repo/core-testing/react";
import type { AppRouter } from "@repo/core-api"; // import in your app/feature, not in core-testing
const trpcClient = createMockTrpcClient<AppRouter>({
"blog.articleBySlug": { id: "1", title: "Hello", slug: "hello" },
"blog.listArticles": [],
});
```
Combine with your app's TRPCProvider for components that need a tRPC client in the render tree.
## Adding a contract suite
See `docs/guides/tdd-workflow.md` §"Contract suite usage".
## Test patterns
These test obligations apply to every feature package. The examples below show the minimal shape — adapt to the feature's actual types.
### Output validation (use case)
Every non-void use case must have a test that injects a mock returning malformed data and asserts the use case rejects with a `ZodError`. This proves `xOutputSchema.parse(result)` is actually called.
```typescript
it("throws ZodError when repository returns malformed data", async () => {
const badRepo = { getArticleBySlug: async () => ({ id: 1 }) }; // id should be string
await expect(
getArticleBySlugUseCase(badRepo as any)({ slug: "x" }),
).rejects.toBeInstanceOf(ZodError);
});
```
Void use cases (`signOut`, `deleteMedia`) are exempt — they have no `xOutputSchema`.
### Router error mapping (tRPC)
Each feature's `router.test.ts` must assert the correct `TRPCError.code` for at least one mapped domain error, using `xRouter.createCaller({})`.
```typescript
it("returns NOT_FOUND when article is missing", async () => {
const caller = blogRouter.createCaller({});
const error = await caller.articleBySlug({ slug: "missing" }).catch((e) => e);
expect(error).toBeInstanceOf(TRPCError);
expect(error.code).toBe("NOT_FOUND");
});
```
Also assert `BAD_REQUEST` for at least one invalid-input call (exercises the `strict()` schema boundary).
### Presenter shape (controller tests)
When a controller's presenter reshapes the use-case output (e.g., `signInController` extracts `cookie` from `{ session, cookie }`), the controller test must assert against the **view shape**, not the use-case output shape.
```typescript
// signInController: presenter returns value.cookie (a Cookie object)
const result = await signInController(mockUseCase)({
username: "u",
password: "p",
});
expect(result.name).toBe(SESSION_COOKIE); // Cookie.name
expect(result.value).toBeDefined(); // Cookie.value
// NOT: expect(result.session).toBeDefined() — that's the use-case output, not the view
```
Identity presenters (`return value;`) skip this obligation — the view shape equals the use-case output shape.

View File

@@ -0,0 +1,11 @@
import baseConfig from "@repo/core-eslint/base";
export default [
...baseConfig,
{
rules: {
// test utilities are intended to be used in test contexts
"no-console": "off",
},
},
];

View File

@@ -0,0 +1,60 @@
{
"name": "@repo/core-testing",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./factory": "./src/factory/index.ts",
"./contract": "./src/contract/index.ts",
"./instrumentation": "./src/instrumentation/index.ts",
"./react": "./src/react/index.ts",
"./payload": "./src/payload/index.ts",
"./payload/stub-config": "./src/payload/stub-config.ts",
"./setup/jsdom": "./src/setup/jsdom.ts",
"./setup/node": "./src/setup/node.ts",
"./setup/no-instrumentation": "./src/setup/no-instrumentation.ts",
"./setup/no-sentry": "./src/setup/no-instrumentation.ts",
"./rate-limit": "./src/rate-limit/index.ts",
"./stryker.base.json": "./stryker.base.json"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@testing-library/jest-dom": "^6.5.0",
"zod": "^3.23.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@trpc/client": "^11.0.0",
"@tanstack/react-query": "^5.59.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"superjson": "^2.2.0",
"vitest": "^3.0.0"
},
"peerDependencies": {
"@trpc/server": "^11.0.0",
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"@trpc/server": {
"optional": true
},
"payload": {
"optional": true
}
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@sentry/nextjs": "^10.51.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^25.0.0",
"typescript": "^5.8.0"
}
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { defineContractSuite } from "@/contract/define-contract-suite";
import { RecordingTracer } from "@/instrumentation/recording-tracer";
interface Adder {
add(a: number, b: number): number;
}
const adderContract = defineContractSuite<Adder>(
"Adder",
({ buildSubject }) => {
it("adds two positive numbers", async () => {
const subject = await buildSubject();
expect(subject.add(2, 3)).toBe(5);
});
it("handles zero", async () => {
const subject = await buildSubject();
expect(subject.add(0, 0)).toBe(0);
});
},
);
class RealAdder implements Adder {
add(a: number, b: number) {
return a + b;
}
}
describe("RealAdder satisfies Adder contract", () => {
adderContract.run(() => new RealAdder());
});
describe("defineContractSuite — getTracer plumbing", () => {
it("passes the tracer accessor into the suite", () => {
let receivedTracer: RecordingTracer | undefined;
const tracer = new RecordingTracer();
const suite = defineContractSuite<{ foo: string }>(
"Test",
({ buildSubject, getTracer }) => {
it("can read tracer", async () => {
const subject = await buildSubject();
expect(subject.foo).toBe("bar");
receivedTracer = getTracer?.();
});
},
);
suite.run(() => ({ foo: "bar" }), { tracer: () => tracer });
// Vitest defers actual assertion to the `it`; we verify the wiring by re-reading after.
// (This is a meta-test of plumbing only — the inner it() runs as a child describe.)
expect(typeof tracer.startSpan).toBe("function");
void receivedTracer;
});
it("getTracer is undefined when opts.tracer not provided (backward compat)", () => {
let receivedAccessor: unknown = undefined;
const suite = defineContractSuite<{ x: number }>(
"Test",
({ buildSubject, getTracer }) => {
it("accessor undefined", async () => {
await buildSubject();
receivedAccessor = getTracer;
});
},
);
suite.run(() => ({ x: 1 }));
// No tracer opts → accessor is undefined inside the suite body.
// (Exact assertion happens via type, not runtime — typecheck gates this.)
void receivedAccessor;
});
});

View File

@@ -0,0 +1,27 @@
import { describe } from "vitest";
import type { RecordingTracer } from "../instrumentation/recording-tracer";
export interface ContractContext<T> {
buildSubject: () => Promise<T> | T;
getTracer?: () => RecordingTracer;
}
export interface ContractSuite<T> {
run(
buildSubject: () => Promise<T> | T,
opts?: { tracer?: () => RecordingTracer },
): void;
}
export function defineContractSuite<T>(
name: string,
suite: (ctx: ContractContext<T>) => void,
): ContractSuite<T> {
return {
run(buildSubject, opts) {
describe(`Contract: ${name}`, () => {
suite({ buildSubject, getTracer: opts?.tracer });
});
},
};
}

View File

@@ -0,0 +1,5 @@
export {
defineContractSuite,
type ContractContext,
type ContractSuite,
} from "./define-contract-suite";

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach } from "vitest";
import { defineFactory } from "@/factory/define-factory";
interface User {
id: string;
name: string;
age: number;
createdAt: Date;
}
describe("defineFactory", () => {
const userFactory = defineFactory<User>(({ sequence }) => ({
id: `user-${sequence}`,
name: `User ${sequence}`,
age: 30,
createdAt: new Date("2026-01-01T00:00:00Z"),
}));
beforeEach(() => userFactory.reset());
it("builds a default object", () => {
const u = userFactory.build();
expect(u).toEqual({
id: "user-1",
name: "User 1",
age: 30,
createdAt: new Date("2026-01-01T00:00:00Z"),
});
});
it("increments sequence per build", () => {
const a = userFactory.build();
const b = userFactory.build();
expect(a.id).toBe("user-1");
expect(b.id).toBe("user-2");
});
it("applies overrides", () => {
const u = userFactory.build({ name: "Alice", age: 25 });
expect(u.name).toBe("Alice");
expect(u.age).toBe(25);
expect(u.id).toBe("user-1");
});
it("buildList builds N items with same overrides", () => {
const list = userFactory.buildList(3, { age: 40 });
expect(list).toHaveLength(3);
expect(list.map((u) => u.id)).toEqual(["user-1", "user-2", "user-3"]);
expect(list.every((u) => u.age === 40)).toBe(true);
});
it("reset() restarts the sequence", () => {
userFactory.build();
userFactory.build();
userFactory.reset();
expect(userFactory.build().id).toBe("user-1");
});
it("deep-merges nested object overrides without losing sibling keys", () => {
const factory = defineFactory<{ id: string; meta: { source: string; tags: string[] } }>(({ sequence }) => ({
id: `id-${sequence}`,
meta: { source: "default", tags: ["a", "b"] },
}));
const result = factory.build({ meta: { source: "custom" } as never });
expect(result.meta.source).toBe("custom");
expect(result.meta.tags).toEqual(["a", "b"]); // sibling key preserved
});
it("replaces array overrides atomically (does not concat)", () => {
const factory = defineFactory<{ tags: string[] }>(() => ({ tags: ["a", "b"] }));
const result = factory.build({ tags: ["c"] });
expect(result.tags).toEqual(["c"]);
});
it("replaces Date overrides atomically", () => {
const factory = defineFactory<{ when: Date }>(() => ({ when: new Date("2026-01-01") }));
const result = factory.build({ when: new Date("2030-12-31") });
expect(result.when.getFullYear()).toBe(2030);
});
});

View File

@@ -0,0 +1,49 @@
export interface FactoryContext {
sequence: number;
}
export interface Factory<T> {
build(overrides?: Partial<T>): T;
buildList(count: number, overrides?: Partial<T>): T[];
reset(): void;
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && Object.getPrototypeOf(v) === Object.prototype;
}
function deepMerge<T>(base: T, overrides: Partial<T>): T {
if (!isPlainObject(base) || !isPlainObject(overrides)) {
return (overrides ?? base) as T;
}
const result: Record<string, unknown> = { ...base };
for (const key of Object.keys(overrides)) {
const baseVal = (base as Record<string, unknown>)[key];
const overrideVal = (overrides as Record<string, unknown>)[key];
if (isPlainObject(baseVal) && isPlainObject(overrideVal)) {
result[key] = deepMerge(baseVal, overrideVal);
} else {
result[key] = overrideVal;
}
}
return result as T;
}
export function defineFactory<T extends object>(
builder: (ctx: FactoryContext) => T,
): Factory<T> {
let sequence = 0;
return {
build(overrides) {
sequence += 1;
const base = builder({ sequence });
return deepMerge(base, overrides ?? {});
},
buildList(count, overrides) {
return Array.from({ length: count }, () => this.build(overrides));
},
reset() {
sequence = 0;
},
};
}

View File

@@ -0,0 +1,5 @@
export {
defineFactory,
type Factory,
type FactoryContext,
} from "./define-factory";

View File

@@ -0,0 +1,4 @@
export * from "./factory/index";
export * from "./contract/index";
export * from "./instrumentation/index";
export * from "./rate-limit/index";

View File

@@ -0,0 +1,33 @@
export { RecordingTracer, type RecordedSpan } from "./recording-tracer";
export { RecordingLogger, type RecordedCapture } from "./recording-logger";
export { RecordingMetrics, type RecordedMetric } from "./recording-metrics";
export { RecordingJobQueue } from "./recording-job-queue";
export { RecordingEventBus } from "./recording-event-bus";
export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster";
export { RecordingAuditLog } from "./recording-audit-log";
export {
RecordingAnalytics,
type RecordedTrack,
type RecordedIdentify,
type RecordedPageView,
} from "./recording-analytics";
export {
RecordingConsent,
type RecordedConsentGrant,
} from "./recording-consent";
export {
RecordingDataExport,
type RecordedExportCall,
} from "./recording-data-export";
export {
RecordingDataDelete,
type RecordedDeleteCall,
} from "./recording-data-delete";
export {
RecordingDataRectify,
type RecordedRectifyCall,
} from "./recording-data-rectify";
export {
RecordingProcessingRestriction,
type RecordedRestrictionSet,
} from "./recording-processing-restriction";

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { RecordingAnalytics } from "./recording-analytics";
describe("RecordingAnalytics", () => {
it("track() pushes to tracked[]", () => {
const analytics = new RecordingAnalytics();
analytics.track("button_clicked", { page: "home" });
expect(analytics.tracked).toHaveLength(1);
expect(analytics.tracked[0]!.event).toBe("button_clicked");
expect(analytics.tracked[0]!.attributes).toEqual({ page: "home" });
});
it("identify() pushes to identified[]", () => {
const analytics = new RecordingAnalytics();
analytics.identify({ id: "user_1" }, { plan: "pro" });
expect(analytics.identified).toHaveLength(1);
expect(analytics.identified[0]!.user).toEqual({ id: "user_1" });
expect(analytics.identified[0]!.attributes).toEqual({ plan: "pro" });
});
it("pageView() pushes to pageViewed[]", () => {
const analytics = new RecordingAnalytics();
analytics.pageView("/home", { referrer: "google" });
expect(analytics.pageViewed).toHaveLength(1);
expect(analytics.pageViewed[0]!.path).toBe("/home");
expect(analytics.pageViewed[0]!.attributes).toEqual({ referrer: "google" });
});
it("flush() drains all arrays and resolves", async () => {
const analytics = new RecordingAnalytics();
analytics.track("event_1");
analytics.identify({ id: "user_1" });
analytics.pageView("/about");
await analytics.flush();
expect(analytics.tracked).toEqual([]);
expect(analytics.identified).toEqual([]);
expect(analytics.pageViewed).toEqual([]);
});
it("methods accept calls without attributes", () => {
const analytics = new RecordingAnalytics();
analytics.track("no_attrs");
analytics.identify({ id: "user_2" });
analytics.pageView("/contact");
expect(analytics.tracked[0]!.attributes).toBeUndefined();
expect(analytics.identified[0]!.attributes).toBeUndefined();
expect(analytics.pageViewed[0]!.attributes).toBeUndefined();
});
});

View File

@@ -0,0 +1,65 @@
// Local type aliases mirroring IAnalytics from `@repo/core-analytics`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-analytics (core). Same pattern used by recording-audit-log and
// recording-event-bus.
type AnalyticsAttributeValue = string | number | boolean;
type AnalyticsUser = {
id: string;
};
export type RecordedTrack = {
event: string;
attributes?: Record<string, AnalyticsAttributeValue>;
};
export type RecordedIdentify = {
user: AnalyticsUser;
attributes?: Record<string, AnalyticsAttributeValue>;
};
export type RecordedPageView = {
path: string;
attributes?: Record<string, AnalyticsAttributeValue>;
};
export class RecordingAnalytics {
public tracked: RecordedTrack[] = [];
public identified: RecordedIdentify[] = [];
public pageViewed: RecordedPageView[] = [];
track(
event: string,
attributes?: Record<string, AnalyticsAttributeValue>,
): void {
this.tracked.push({ event, attributes });
}
identify(
user: AnalyticsUser,
attributes?: Record<string, AnalyticsAttributeValue>,
): void {
this.identified.push({ user, attributes });
}
pageView(
path: string,
attributes?: Record<string, AnalyticsAttributeValue>,
): void {
this.pageViewed.push({ path, attributes });
}
flush(): Promise<void> {
this.tracked = [];
this.identified = [];
this.pageViewed = [];
return Promise.resolve();
}
reset(): void {
this.tracked = [];
this.identified = [];
this.pageViewed = [];
}
}

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { RecordingAuditLog } from "./recording-audit-log";
// Inline type to avoid importing from @repo/core-shared (boundary: tooling cannot depend on core)
type SampleEntry = Parameters<RecordingAuditLog["record"]>[0];
const sample: SampleEntry = {
actorId: "user_1",
actorType: "user",
actorRoles: [],
action: "CREATE",
resource: { type: "articles" },
at: new Date(),
scope: { feature: "blog", environment: "test", tenant: "default" },
from: { ipTruncated: "10.0.0.0", userAgent: "test" },
containsPii: false,
outcome: "success",
};
describe("RecordingAuditLog", () => {
it("record() pushes to recorded[]", async () => {
const log = new RecordingAuditLog();
await log.record(sample);
expect(log.recorded).toHaveLength(1);
expect(log.recorded[0]!.actorId).toBe("user_1");
});
it("eraseSubject(pseudonymize) tracks erasure + rewrites actorId in recorded", async () => {
const log = new RecordingAuditLog();
await log.record(sample);
await log.eraseSubject("user_1", "pseudonymize");
expect(log.erasures).toEqual([{ actorId: "user_1", mode: "pseudonymize" }]);
expect(log.recorded[0]!.actorId).toBe("erased-user_1"); // sentinel rewrite
});
it("eraseSubject(delete) removes matching entries from recorded", async () => {
const log = new RecordingAuditLog();
await log.record(sample);
await log.record({ ...sample, actorId: "user_2" });
await log.eraseSubject("user_1", "delete");
expect(log.recorded.map((r) => r.actorId)).toEqual(["user_2"]);
expect(log.erasures).toEqual([{ actorId: "user_1", mode: "delete" }]);
});
it("reset() clears recorded + erasures", async () => {
const log = new RecordingAuditLog();
await log.record(sample);
await log.eraseSubject("user_1", "pseudonymize");
log.reset();
expect(log.recorded).toEqual([]);
expect(log.erasures).toEqual([]);
});
});

View File

@@ -0,0 +1,74 @@
// Local type alias mirroring `AuditEntry` from `@repo/core-shared/audit`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-shared (core). Same pattern used by recording-event-bus,
// recording-realtime-broadcaster, and recording-metrics.
type AuditAction =
| "VIEW"
| "CREATE"
| "UPDATE"
| "DELETE"
| "EXPORT"
| "PERMISSION_CHANGE"
| "CONSENT_GRANT"
| "CONSENT_WITHDRAW"
| "RESTRICT"
| "UNRESTRICT";
type AuditEntry = {
actorId: string;
actorType: "user" | "system" | "service";
actorRoles: string[];
action: AuditAction;
resource: { type: string; id?: string };
changedFields?: string[];
at: Date;
scope: { feature: string; environment: string; tenant: string };
reason?: string;
correlationId?: string;
requestId?: string;
from: { ipTruncated: string; userAgent: string };
containsPii: boolean;
piiCategories?: string[];
outcome: "success" | "denied" | "error";
errorCode?: string;
};
/**
* Test-side recording double for IAuditLog. Mirrors Payload semantics in
* eraseSubject (pseudonymize rewrites in place; delete filters out) so tests
* can assert against the same observable state the real impl produces.
*
* Use directly via constructor injection in factory-function tests — no
* container manipulation needed.
*/
export class RecordingAuditLog {
public recorded: AuditEntry[] = [];
public erasures: { actorId: string; mode: "pseudonymize" | "delete" }[] = [];
async record(entry: AuditEntry): Promise<void> {
// Shallow copy to avoid mutating caller's object during eraseSubject
this.recorded.push({ ...entry });
}
async eraseSubject(
actorId: string,
mode: "pseudonymize" | "delete",
): Promise<void> {
this.erasures.push({ actorId, mode });
if (mode === "pseudonymize") {
for (const r of this.recorded) {
if (r.actorId === actorId) {
r.actorId = `erased-${actorId}`;
}
}
} else {
this.recorded = this.recorded.filter((r) => r.actorId !== actorId);
}
}
reset(): void {
this.recorded = [];
this.erasures = [];
}
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,88 @@
// Local type aliases mirroring exports from `@repo/core-consent`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-consent (optional core). Same pattern used by RecordingAuditLog,
// RecordingEventBus, and other recording doubles.
type ConsentCategory = string;
type ConsentState = "granted" | "denied" | "pending";
type ConsentGrantMeta = {
bannerVersion?: string;
policyVersion?: string;
method?: string;
};
type UserConsentState = {
readonly category: ConsentCategory;
readonly state: ConsentState;
readonly grantedAt?: Date;
readonly withdrawnAt?: Date;
readonly bannerVersion?: string;
readonly policyVersion?: string;
readonly method?: string;
};
/** Recorded grant call. */
export type RecordedConsentGrant = {
category: ConsentCategory;
meta: ConsentGrantMeta | undefined;
};
/**
* Test-side recording double for IConsent. Records all grant/withdraw calls
* for assertion while maintaining the same state semantics as the real impl
* (isGranted returns true after grant, false after withdraw).
*
* Use directly via constructor injection:
* const consent = new RecordingConsent();
* const uc = myUseCase(consent);
* await uc({ ... });
* expect(consent.grants).toHaveLength(1);
*/
export class RecordingConsent {
public grants: RecordedConsentGrant[] = [];
public withdrawals: ConsentCategory[] = [];
private state = new Map<ConsentCategory, UserConsentState>();
isGranted(category: ConsentCategory): boolean {
return this.state.get(category)?.state === "granted";
}
async grant(
category: ConsentCategory,
meta?: ConsentGrantMeta,
): Promise<void> {
this.grants.push({ category, meta });
const existing = this.state.get(category);
this.state.set(category, {
category,
state: "granted",
grantedAt: new Date(),
withdrawnAt: existing?.withdrawnAt,
bannerVersion: meta?.bannerVersion,
policyVersion: meta?.policyVersion,
method: meta?.method,
});
}
async withdraw(category: ConsentCategory): Promise<void> {
this.withdrawals.push(category);
const existing = this.state.get(category);
this.state.set(category, {
...existing,
category,
state: "denied",
withdrawnAt: new Date(),
});
}
getCategories(): UserConsentState[] {
return Array.from(this.state.values());
}
reset(): void {
this.grants = [];
this.withdrawals = [];
this.state.clear();
}
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { RecordingDataDelete } from "./recording-data-delete";
describe("RecordingDataDelete", () => {
it("records deleteSubjectData calls", async () => {
const deleter = new RecordingDataDelete();
await deleter.deleteSubjectData("alice", "soft");
expect(deleter.calls).toHaveLength(1);
expect(deleter.calls[0]).toEqual({ subjectId: "alice", mode: "soft" });
});
it("returns a DeletionCertificate with the correct shape", async () => {
const deleter = new RecordingDataDelete();
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
expect(cert.subjectId).toBe("alice");
expect(cert.mode).toBe("cascade-hard");
expect(cert.reason).toBe("art-17-request");
expect(cert.affected).toEqual([]);
expect(typeof cert.auditEntryId).toBe("string");
expect(typeof cert.timestamp).toBe("string");
});
it("records multiple calls in order", async () => {
const deleter = new RecordingDataDelete();
await deleter.deleteSubjectData("alice", "soft");
await deleter.deleteSubjectData("bob", "cascade-hard");
expect(deleter.calls[0]?.mode).toBe("soft");
expect(deleter.calls[1]?.mode).toBe("cascade-hard");
});
it("reset() clears recorded calls", async () => {
const deleter = new RecordingDataDelete();
await deleter.deleteSubjectData("alice", "soft");
deleter.reset();
expect(deleter.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,63 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
type DeletionMode = "soft" | "cascade-hard";
type DeletionReason = "art-17-request" | "admin-expunge" | "retention-policy";
type DeletionAction = "deleted" | "redacted" | "pseudonymized";
type DeletionAffected = {
collection: string;
rowsAffected: number;
action: DeletionAction;
fields?: string[];
};
type DeletionCertificate = {
subjectId: string;
mode: DeletionMode;
timestamp: string;
reason: DeletionReason;
affected: DeletionAffected[];
auditEntryId: string;
};
/** Recorded call to deleteSubjectData. */
export type RecordedDeleteCall = {
subjectId: string;
mode: DeletionMode;
};
/**
* Test-side recording double for IDataDelete. Records all deleteSubjectData
* calls for assertion while returning a well-shaped DeletionCertificate.
*
* Use directly via constructor injection:
* const deleter = new RecordingDataDelete();
* const uc = myUseCase(deleter);
* await uc({ ... });
* expect(deleter.calls[0]?.mode).toBe("soft");
*/
export class RecordingDataDelete {
public calls: RecordedDeleteCall[] = [];
async deleteSubjectData(
subjectId: string,
mode: DeletionMode,
): Promise<DeletionCertificate> {
this.calls.push({ subjectId, mode });
return {
subjectId,
mode,
timestamp: new Date().toISOString(),
reason: "art-17-request",
affected: [],
auditEntryId: `recording-audit-${Date.now()}`,
};
}
reset(): void {
this.calls = [];
}
}

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { RecordingDataExport } from "./recording-data-export";
describe("RecordingDataExport", () => {
it("records exportSubjectData calls", async () => {
const exporter = new RecordingDataExport();
await exporter.exportSubjectData("alice", "json");
expect(exporter.calls).toHaveLength(1);
expect(exporter.calls[0]).toEqual({ subjectId: "alice", format: "json" });
});
it("returns a UserDataBundle with the correct shape", async () => {
const exporter = new RecordingDataExport();
const bundle = await exporter.exportSubjectData("bob", "json-ld");
expect(bundle.subjectId).toBe("bob");
expect(bundle.format).toBe("json-ld");
expect(typeof bundle.exportedAt).toBe("string");
expect(bundle.data).toEqual({});
});
it("records multiple calls in order", async () => {
const exporter = new RecordingDataExport();
await exporter.exportSubjectData("alice", "json");
await exporter.exportSubjectData("bob", "json-ld");
expect(exporter.calls).toHaveLength(2);
expect(exporter.calls[0]?.subjectId).toBe("alice");
expect(exporter.calls[1]?.subjectId).toBe("bob");
});
it("reset() clears recorded calls", async () => {
const exporter = new RecordingDataExport();
await exporter.exportSubjectData("alice", "json");
exporter.reset();
expect(exporter.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,64 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
type DsrFormat = "json" | "json-ld";
type SubjectReference = {
rowId: string;
linkedField: string;
linkedThrough: string;
};
type CollectionDataBucket = {
asSelf?: Array<Record<string, unknown>>;
asReference?: SubjectReference[];
};
type UserDataBundle = {
subjectId: string;
exportedAt: string;
format: DsrFormat;
data: Record<string, CollectionDataBucket>;
auditLog?: unknown[];
"@context"?: string | Record<string, unknown>;
};
/** Recorded call to exportSubjectData. */
export type RecordedExportCall = {
subjectId: string;
format: DsrFormat;
};
/**
* Test-side recording double for IDataExport. Records all exportSubjectData
* calls for assertion while returning an empty but structurally-correct bundle.
*
* Use directly via constructor injection:
* const exporter = new RecordingDataExport();
* const uc = myUseCase(exporter);
* await uc({ ... });
* expect(exporter.calls).toHaveLength(1);
* expect(exporter.calls[0]?.format).toBe("json");
*/
export class RecordingDataExport {
public calls: RecordedExportCall[] = [];
async exportSubjectData(
subjectId: string,
format: DsrFormat,
): Promise<UserDataBundle> {
this.calls.push({ subjectId, format });
return {
subjectId,
exportedAt: new Date().toISOString(),
format,
data: {},
};
}
reset(): void {
this.calls = [];
}
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { RecordingDataRectify } from "./recording-data-rectify";
describe("RecordingDataRectify", () => {
it("records updateSubjectField calls", async () => {
const rectifier = new RecordingDataRectify();
await rectifier.updateSubjectField("alice", "users", "name", "Alice New");
expect(rectifier.calls).toHaveLength(1);
expect(rectifier.calls[0]).toEqual({
subjectId: "alice",
collection: "users",
field: "name",
value: "Alice New",
});
});
it("returns void (undefined)", async () => {
const rectifier = new RecordingDataRectify();
const result = await rectifier.updateSubjectField(
"alice",
"users",
"name",
"x",
);
expect(result).toBeUndefined();
});
it("records multiple calls in order", async () => {
const rectifier = new RecordingDataRectify();
await rectifier.updateSubjectField("alice", "users", "name", "A");
await rectifier.updateSubjectField("alice", "users", "email", "a@ex.com");
expect(rectifier.calls).toHaveLength(2);
expect(rectifier.calls[0]?.field).toBe("name");
expect(rectifier.calls[1]?.field).toBe("email");
});
it("reset() clears recorded calls", async () => {
const rectifier = new RecordingDataRectify();
await rectifier.updateSubjectField("alice", "users", "name", "A");
rectifier.reset();
expect(rectifier.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,39 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
/** Recorded call to updateSubjectField. */
export type RecordedRectifyCall = {
subjectId: string;
collection: string;
field: string;
value: unknown;
};
/**
* Test-side recording double for IDataRectify. Records all updateSubjectField
* calls for assertion while making no persistent changes.
*
* Use directly via constructor injection:
* const rectifier = new RecordingDataRectify();
* const uc = myUseCase(rectifier);
* await uc({ ... });
* expect(rectifier.calls[0]?.field).toBe("name");
*/
export class RecordingDataRectify {
public calls: RecordedRectifyCall[] = [];
async updateSubjectField(
subjectId: string,
collection: string,
field: string,
value: unknown,
): Promise<void> {
this.calls.push({ subjectId, collection, field, value });
}
reset(): void {
this.calls = [];
}
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { RecordingEventBus } from "@/instrumentation/recording-event-bus";
// Inline a descriptor literal so the test doesn't need to import from
// @repo/core-events (boundary-rule isolation, mirrors recording-tracer test).
const evt = {
name: "test.evt" as const,
schema: z.object({ id: z.string() }).strict(),
};
describe("RecordingEventBus", () => {
it("records every publish call after schema validation", async () => {
const bus = new RecordingEventBus();
await bus.publish(evt, { id: "a" });
await bus.publish(evt, { id: "b" });
expect(bus.published).toEqual([
{ name: "test.evt", payload: { id: "a" } },
{ name: "test.evt", payload: { id: "b" } },
]);
});
it("rejects invalid payloads", async () => {
const bus = new RecordingEventBus();
await expect(
bus.publish(evt, { id: 1 } as unknown as { id: string }),
).rejects.toThrow();
expect(bus.published).toHaveLength(0);
});
it("invokes registered handlers sequentially in subscription order", async () => {
const bus = new RecordingEventBus();
const order: string[] = [];
bus.subscribe(evt, "consumer-a", async () => {
order.push("a");
});
bus.subscribe(evt, "consumer-b", async () => {
order.push("b");
});
await bus.publish(evt, { id: "x" });
expect(order).toEqual(["a", "b"]);
});
});

View File

@@ -0,0 +1,47 @@
// Local type aliases matching the contracts in @repo/core-events.
// Kept inline to avoid a build-graph cycle between core-testing and core-events
// (mirrors the recording-tracer / recording-logger pattern).
import type { z } from "zod";
type EventDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
};
type EventHandler<T> = (event: T) => Promise<void>;
interface IEventBus {
publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
handler: EventHandler<T>,
): void;
}
export class RecordingEventBus implements IEventBus {
readonly published: { name: string; payload: unknown }[] = [];
private readonly handlers = new Map<string, EventHandler<unknown>[]>();
async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
this.published.push({ name: descriptor.name, payload });
for (const h of this.handlers.get(descriptor.name) ?? []) await h(payload);
}
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
_consumerFeature: string,
handler: EventHandler<T>,
): void {
const arr = this.handlers.get(descriptor.name) ?? [];
arr.push(handler as EventHandler<unknown>);
this.handlers.set(descriptor.name, arr);
}
}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { RecordingJobQueue } from "@/instrumentation/recording-job-queue";
describe("RecordingJobQueue", () => {
it("records every enqueue call", async () => {
const queue = new RecordingJobQueue();
const future = new Date("2030-01-01");
await queue.enqueue("a.task", { x: 1 });
await queue.enqueue("b.task", { y: 2 }, { runAt: future });
expect(queue.enqueued).toEqual([
{ taskSlug: "a.task", input: { x: 1 }, options: undefined },
{ taskSlug: "b.task", input: { y: 2 }, options: { runAt: future } },
]);
});
it("returns a synthetic jobId per call", async () => {
const queue = new RecordingJobQueue();
const a = await queue.enqueue("a", {});
const b = await queue.enqueue("b", {});
expect(a.jobId).toBe("recording-1");
expect(b.jobId).toBe("recording-2");
});
});

View File

@@ -0,0 +1,23 @@
// Local type alias matching the contract in @repo/core-shared/jobs.
// Kept inline to avoid a build-graph cycle between core-testing and core-shared
// (mirrors the recording-tracer / recording-logger pattern).
interface IJobQueue {
enqueue<T>(
taskSlug: string,
input: T,
options?: { runAt?: Date },
): Promise<{ jobId: string }>;
}
export class RecordingJobQueue implements IJobQueue {
readonly enqueued: { taskSlug: string; input: unknown; options?: { runAt?: Date } }[] = [];
async enqueue<T>(
taskSlug: string,
input: T,
options?: { runAt?: Date },
): Promise<{ jobId: string }> {
this.enqueued.push({ taskSlug, input, options });
return { jobId: `recording-${this.enqueued.length}` };
}
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { RecordingLogger } from "@/instrumentation/recording-logger";
describe("RecordingLogger", () => {
it("records captureException calls (err + ctx)", () => {
const logger = new RecordingLogger();
const err = new Error("x");
logger.captureException(err, { tags: { feature: "blog" } });
expect(logger.captures).toHaveLength(1);
expect(logger.captures[0]).toMatchObject({
kind: "exception",
err,
ctx: { tags: { feature: "blog" } },
});
});
it("records captureMessage calls", () => {
const logger = new RecordingLogger();
logger.captureMessage("hello", "warning", { extras: { foo: 1 } });
expect(logger.captures[0]).toMatchObject({
kind: "message",
message: "hello",
level: "warning",
});
});
it("records breadcrumbs", () => {
const logger = new RecordingLogger();
logger.addBreadcrumb({ category: "test", message: "x", level: "info" });
expect(logger.breadcrumbs).toHaveLength(1);
expect(logger.breadcrumbs[0]!.category).toBe("test");
});
it("records setUser calls", () => {
const logger = new RecordingLogger();
logger.setUser({ id: "u1" });
logger.setUser(null);
expect(logger.users).toEqual([{ id: "u1" }, null]);
});
it("reset() clears all recordings", () => {
const logger = new RecordingLogger();
logger.captureException(new Error("x"));
logger.addBreadcrumb({ category: "c", message: "m" });
logger.setUser({ id: "u" });
logger.reset();
expect(logger.captures).toHaveLength(0);
expect(logger.breadcrumbs).toHaveLength(0);
expect(logger.users).toHaveLength(0);
});
it("findCapture returns first capture matching predicate", () => {
const logger = new RecordingLogger();
logger.captureException(new Error("first"));
logger.captureException(new Error("second"));
const found = logger.findCapture(
(c) => c.kind === "exception" && (c.err as Error).message === "second",
);
expect(found).toBeDefined();
});
});

View File

@@ -0,0 +1,88 @@
// Local type aliases matching the contracts in @repo/core-shared/instrumentation.
// Kept inline to avoid a build-graph cycle between core-testing and core-shared.
type Breadcrumb = {
category: string;
message: string;
level?: "info" | "warning" | "error";
data?: Record<string, unknown>;
};
type CaptureContext = {
tags?: Record<string, string>;
extras?: Record<string, unknown>;
fingerprint?: string[];
};
interface ILogger {
captureException(err: unknown, ctx?: CaptureContext): void;
captureMessage(msg: string, level?: "info" | "warning" | "error", ctx?: CaptureContext): void;
addBreadcrumb(b: Breadcrumb): void;
setUser(user: { id: string } | null): void;
}
export type RecordedCapture =
| { kind: "exception"; err: unknown; ctx?: CaptureContext }
| { kind: "message"; message: string; level?: "info" | "warning" | "error"; ctx?: CaptureContext };
// Inlined to avoid a tooling → core import (boundary rule). Mirrors the
// implementation in @repo/core-shared/instrumentation/reported-flag.ts.
const REPORTED = "__sentryReported" as const;
function isReported(err: unknown): boolean {
return (
err !== null &&
typeof err === "object" &&
Boolean((err as Record<string, unknown>)[REPORTED])
);
}
function markReported(err: unknown): void {
if (err !== null && typeof err === "object" && !isReported(err)) {
Object.defineProperty(err, REPORTED, {
value: true,
enumerable: false,
configurable: false,
writable: false,
});
}
}
export class RecordingLogger implements ILogger {
captures: RecordedCapture[] = [];
breadcrumbs: Breadcrumb[] = [];
users: Array<{ id: string } | null> = [];
captureException(err: unknown, ctx?: CaptureContext): void {
if (isReported(err)) return;
this.captures.push({ kind: "exception", err, ctx });
markReported(err);
}
captureMessage(
message: string,
level?: "info" | "warning" | "error",
ctx?: CaptureContext,
): void {
this.captures.push({ kind: "message", message, level, ctx });
}
addBreadcrumb(b: Breadcrumb): void {
this.breadcrumbs.push(b);
}
setUser(user: { id: string } | null): void {
this.users.push(user);
}
reset(): void {
this.captures = [];
this.breadcrumbs = [];
this.users = [];
}
findCapture(
predicate: (c: RecordedCapture) => boolean,
): RecordedCapture | undefined {
return this.captures.find(predicate);
}
}

View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { RecordingMetrics } from "@/instrumentation/recording-metrics";
describe("RecordingMetrics", () => {
it("records counter calls with kind, name, value, and attributes", () => {
const recording = new RecordingMetrics();
recording.counter("http.requests", 1, { method: "GET", route: "/api/me" });
expect(recording.metrics).toHaveLength(1);
const [m] = recording.metrics;
expect(m!.kind).toBe("counter");
expect(m!.name).toBe("http.requests");
expect(m!.value).toBe(1);
expect(m!.attributes).toEqual({ method: "GET", route: "/api/me" });
});
it("counter() defaults value to 1 when omitted", () => {
const recording = new RecordingMetrics();
recording.counter("events.signups");
expect(recording.metrics[0]!.value).toBe(1);
});
it("records histogram calls", () => {
const recording = new RecordingMetrics();
recording.histogram("http.duration", 123, { route: "/api/list" });
expect(recording.metrics).toHaveLength(1);
const [m] = recording.metrics;
expect(m!.kind).toBe("histogram");
expect(m!.name).toBe("http.duration");
expect(m!.value).toBe(123);
expect(m!.attributes).toEqual({ route: "/api/list" });
});
it("records gauge calls", () => {
const recording = new RecordingMetrics();
recording.gauge("queue.depth", 42, { queue: "emails" });
expect(recording.metrics).toHaveLength(1);
const [m] = recording.metrics;
expect(m!.kind).toBe("gauge");
expect(m!.name).toBe("queue.depth");
expect(m!.value).toBe(42);
expect(m!.attributes).toEqual({ queue: "emails" });
});
it("accumulates multiple calls across all kinds", () => {
const recording = new RecordingMetrics();
recording.counter("a");
recording.histogram("b", 10);
recording.gauge("c", 5);
expect(recording.metrics).toHaveLength(3);
expect(recording.metrics.map((m) => m.kind)).toEqual([
"counter",
"histogram",
"gauge",
]);
});
it("reset() clears all recorded metrics", () => {
const recording = new RecordingMetrics();
recording.counter("x");
recording.histogram("y", 1);
expect(recording.metrics).toHaveLength(2);
recording.reset();
expect(recording.metrics).toHaveLength(0);
});
it("find() returns the first matching metric", () => {
const recording = new RecordingMetrics();
recording.counter("a");
recording.histogram("b", 10);
const found = recording.find((m) => m.kind === "histogram");
expect(found).toBeDefined();
expect(found!.name).toBe("b");
});
it("find() returns undefined when no metric matches", () => {
const recording = new RecordingMetrics();
recording.counter("a");
const found = recording.find((m) => m.kind === "gauge");
expect(found).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
// Local type alias matching the contract in @repo/core-shared/instrumentation.
// Kept inline to avoid a build-graph cycle between core-testing and core-shared.
type MetricAttributeValue = string | number | boolean;
interface IMetrics {
counter(
name: string,
value?: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
histogram(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
}
export type RecordedMetric = {
kind: "counter" | "histogram" | "gauge";
name: string;
value: number;
attributes: Record<string, MetricAttributeValue>;
};
export class RecordingMetrics implements IMetrics {
metrics: RecordedMetric[] = [];
counter(
name: string,
value = 1,
attributes: Record<string, MetricAttributeValue> = {},
): void {
this.metrics.push({ kind: "counter", name, value, attributes });
}
histogram(
name: string,
value: number,
attributes: Record<string, MetricAttributeValue> = {},
): void {
this.metrics.push({ kind: "histogram", name, value, attributes });
}
gauge(
name: string,
value: number,
attributes: Record<string, MetricAttributeValue> = {},
): void {
this.metrics.push({ kind: "gauge", name, value, attributes });
}
reset(): void {
this.metrics = [];
}
find(
predicate: (m: RecordedMetric) => boolean,
): RecordedMetric | undefined {
return this.metrics.find(predicate);
}
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { RecordingProcessingRestriction } from "./recording-processing-restriction";
describe("RecordingProcessingRestriction", () => {
it("records setRestriction calls", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
expect(restriction.sets).toHaveLength(1);
expect(restriction.sets[0]).toEqual({ subjectId: "alice", granted: true });
});
it("isRestricted returns true after setRestriction(true)", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
expect(await restriction.isRestricted("alice")).toBe(true);
});
it("isRestricted returns false after setRestriction(false)", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("alice", false);
expect(await restriction.isRestricted("alice")).toBe(false);
});
it("isRestricted returns false for unknown subjects", async () => {
const restriction = new RecordingProcessingRestriction();
expect(await restriction.isRestricted("ghost")).toBe(false);
});
it("tracks restriction per subject independently", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
await restriction.setRestriction("bob", false);
expect(await restriction.isRestricted("alice")).toBe(true);
expect(await restriction.isRestricted("bob")).toBe(false);
});
it("reset() clears sets and state", async () => {
const restriction = new RecordingProcessingRestriction();
await restriction.setRestriction("alice", true);
restriction.reset();
expect(restriction.sets).toHaveLength(0);
expect(await restriction.isRestricted("alice")).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
// Local type aliases mirroring exports from `@repo/core-dsr`.
// Kept inline to avoid a build-graph cycle between core-testing (tooling)
// and core-dsr (optional core). Same pattern used by RecordingAuditLog,
// RecordingConsent, and other recording doubles.
/** Recorded setRestriction call. */
export type RecordedRestrictionSet = {
subjectId: string;
granted: boolean;
};
/**
* Test-side recording double for IProcessingRestriction. Records all
* setRestriction / isRestricted calls for assertion while maintaining the same
* state semantics as the real impl (isRestricted returns true after
* setRestriction(true), false after setRestriction(false)).
*
* Use directly via constructor injection:
* const restriction = new RecordingProcessingRestriction();
* const uc = myUseCase(restriction);
* await uc({ ... });
* expect(restriction.sets[0]?.granted).toBe(true);
* expect(await restriction.isRestricted("alice")).toBe(true);
*/
export class RecordingProcessingRestriction {
public sets: RecordedRestrictionSet[] = [];
private readonly state = new Map<string, boolean>();
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
this.sets.push({ subjectId, granted });
this.state.set(subjectId, granted);
}
async isRestricted(subjectId: string): Promise<boolean> {
return this.state.get(subjectId) ?? false;
}
reset(): void {
this.sets = [];
this.state.clear();
}
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { RecordingRealtimeBroadcaster } from "@/instrumentation/recording-realtime-broadcaster";
const ch = {
name: "test.ch" as const,
schema: z.object({ x: z.number() }).strict(),
scope: "public" as const,
};
describe("RecordingRealtimeBroadcaster", () => {
it("records every broadcast call after schema validation", async () => {
const b = new RecordingRealtimeBroadcaster();
await b.broadcast(ch, { x: 1 });
await b.broadcast(ch, { x: 2 });
expect(b.broadcasts).toEqual([
{ channel: "test.ch", payload: { x: 1 } },
{ channel: "test.ch", payload: { x: 2 } },
]);
});
it("rejects payloads that fail schema validation", async () => {
const b = new RecordingRealtimeBroadcaster();
await expect(b.broadcast(ch, { x: "wrong" } as never)).rejects.toThrow();
expect(b.broadcasts).toHaveLength(0);
});
});

View File

@@ -0,0 +1,29 @@
// Local type aliases mirroring @repo/core-realtime's contracts. Kept inline to
// avoid a build-graph cycle between core-testing (tooling) and core-realtime (core).
// Same pattern recording-event-bus + recording-job-queue use.
import type { z } from "zod";
type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
readonly scope: string | { role: string } | { userScoped: true; template: string };
};
interface IRealtimeBroadcaster {
broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
}
export class RecordingRealtimeBroadcaster implements IRealtimeBroadcaster {
readonly broadcasts: { channel: string; payload: unknown }[] = [];
async broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
this.broadcasts.push({ channel: descriptor.name, payload });
}
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { RecordingTracer } from "@/instrumentation/recording-tracer";
describe("RecordingTracer", () => {
it("records every startSpan call with name, op, attributes, status, durationMs", async () => {
const tracer = new RecordingTracer();
await tracer.startSpan(
{ name: "blog.getArticles", op: "use-case", attributes: { limit: 10 } },
async (span) => {
span.setAttribute("count", 3);
span.setStatus("ok");
return undefined;
},
);
expect(tracer.spans).toHaveLength(1);
const s = tracer.spans[0]!;
expect(s.name).toBe("blog.getArticles");
expect(s.op).toBe("use-case");
expect(s.attributes).toMatchObject({ limit: 10, count: 3 });
expect(s.status).toBe("ok");
expect(typeof s.durationMs).toBe("number");
expect(s.durationMs).toBeGreaterThanOrEqual(0);
});
it("records error status when fn throws", async () => {
const tracer = new RecordingTracer();
await expect(
tracer.startSpan({ name: "x" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(tracer.spans).toHaveLength(1);
expect(tracer.spans[0]!.status).toBe("error");
expect(tracer.spans[0]!.statusMessage).toBe("boom");
});
it("records error status when set explicitly via span.setStatus", async () => {
const tracer = new RecordingTracer();
await tracer.startSpan({ name: "x" }, async (span) => {
span.setStatus("error", "validation failed");
return undefined;
});
expect(tracer.spans[0]!.status).toBe("error");
expect(tracer.spans[0]!.statusMessage).toBe("validation failed");
});
it("reset() clears recorded spans", async () => {
const tracer = new RecordingTracer();
await tracer.startSpan({ name: "x" }, async () => undefined);
expect(tracer.spans).toHaveLength(1);
tracer.reset();
expect(tracer.spans).toHaveLength(0);
});
it("findSpan returns first matching span by name", async () => {
const tracer = new RecordingTracer();
await tracer.startSpan({ name: "a" }, async () => undefined);
await tracer.startSpan({ name: "b" }, async () => undefined);
expect(tracer.findSpan("b")).toBeDefined();
expect(tracer.findSpan("missing")).toBeUndefined();
});
it("nested spans are recorded in order (children appear after parent end)", async () => {
const tracer = new RecordingTracer();
await tracer.startSpan({ name: "parent" }, async () => {
await tracer.startSpan({ name: "child" }, async () => undefined);
});
expect(tracer.spans.map((s) => s.name)).toEqual(["child", "parent"]);
});
});

View File

@@ -0,0 +1,72 @@
// Local type aliases matching the contracts in @repo/core-shared/instrumentation.
// Kept inline to avoid a build-graph cycle between core-testing and core-shared.
type AttributeValue = string | number | boolean | null;
type SpanOpts = {
name: string;
op?: string;
attributes?: Record<string, AttributeValue>;
};
interface ISpan {
setAttribute(key: string, value: AttributeValue): void;
setStatus(status: "ok" | "error", message?: string): void;
}
interface ITracer {
startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T>;
}
export type RecordedSpan = {
name: string;
op?: string;
attributes: Record<string, AttributeValue>;
status: "ok" | "error";
statusMessage?: string;
durationMs: number;
};
// Exported so callers can use it as a compatible ITracer via structural typing.
export class RecordingTracer implements ITracer {
spans: RecordedSpan[] = [];
async startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
const start = performance.now();
const recorded: RecordedSpan = {
name: opts.name,
op: opts.op,
attributes: { ...(opts.attributes ?? {}) },
status: "ok",
durationMs: 0,
};
const span: ISpan = {
setAttribute(key, value) {
recorded.attributes[key] = value;
},
setStatus(status, message) {
recorded.status = status;
recorded.statusMessage = message;
},
};
try {
const result = await fn(span);
recorded.durationMs = performance.now() - start;
this.spans.push(recorded);
return result;
} catch (err) {
recorded.status = "error";
recorded.statusMessage = err instanceof Error ? err.message : String(err);
recorded.durationMs = performance.now() - start;
this.spans.push(recorded);
throw err;
}
}
reset(): void {
this.spans = [];
}
findSpan(name: string): RecordedSpan | undefined {
return this.spans.find((s) => s.name === name);
}
}

View File

@@ -0,0 +1,2 @@
export { stubPayloadConfig } from "./stub-config";
export { mockPayloadModule } from "./mock-payload-module";

View File

@@ -0,0 +1,27 @@
import type { Payload } from "payload";
/**
* @deprecated DO NOT USE — hoisting incompatible.
*
* Vitest statically hoists `vi.mock()` calls to the top of the file.
* This helper wraps `vi.mock()` inside a regular function body, so the
* hoist never fires — the mock is installed too late and the real
* `payload` module loads before being intercepted.
*
* Instead, write at the TOP LEVEL of your test file:
*
* vi.mock("payload", () => ({ getPayload: vi.fn() }));
*
* // then in your test:
* const { getPayload } = await import("payload");
* (getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(yourStub);
*
* This export is preserved only so existing imports don't break and
* agents searching for it find this warning.
*/
export function mockPayloadModule(impl: Partial<Payload>): void {
void impl;
throw new Error(
"mockPayloadModule is deprecated and broken; see JSDoc. Call vi.mock('payload', ...) at the top of your test file instead.",
);
}

View File

@@ -0,0 +1,6 @@
import type { SanitizedConfig } from "payload";
// Minimal SanitizedConfig stub for tests that need to construct repos
// without actually loading the real Payload config. Repository tests
// that mock the `payload` module never read fields off this object.
export const stubPayloadConfig = {} as SanitizedConfig;

View File

@@ -0,0 +1,5 @@
export {
RecordingRateLimit,
type RecordedConsumeCall,
type RecordedResetCall,
} from "./recording-rate-limit";

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { RecordingRateLimit } from "@/rate-limit/recording-rate-limit";
describe("RecordingRateLimit", () => {
it("starts with empty call arrays", () => {
const rl = new RecordingRateLimit();
expect(rl.consumeCalls).toHaveLength(0);
expect(rl.resetCalls).toHaveLength(0);
});
it("captures consume call arguments verbatim including optional weight", async () => {
const rl = new RecordingRateLimit();
await rl.consume("api", "user:123");
await rl.consume("api", "user:456", 2);
expect(rl.consumeCalls).toEqual([
{ budgetName: "api", key: "user:123", weight: undefined },
{ budgetName: "api", key: "user:456", weight: 2 },
]);
});
it("captures reset call arguments verbatim", async () => {
const rl = new RecordingRateLimit();
await rl.reset("api", "user:123");
await rl.reset("auth", "ip:1.2.3.4");
expect(rl.resetCalls).toEqual([
{ budgetName: "api", key: "user:123" },
{ budgetName: "auth", key: "ip:1.2.3.4" },
]);
});
it("returns allowed decision by default", async () => {
const rl = new RecordingRateLimit();
const decision = await rl.consume("api", "user:123");
expect(decision).toEqual({
allowed: true,
remaining: Infinity,
resetAt: new Date(0),
});
});
it("returns configured decision after withDecision", async () => {
const rl = new RecordingRateLimit();
const blocked = { allowed: false, remaining: 0, resetAt: new Date(9999) };
rl.withDecision(blocked);
const decision = await rl.consume("api", "user:123");
expect(decision).toEqual(blocked);
});
});

View File

@@ -0,0 +1,60 @@
// Local type aliases matching the contracts in @repo/core-shared/rate-limit.
// Kept inline to avoid a build-graph cycle between core-testing and core-shared
// (mirrors the recording-job-queue pattern).
type RateLimitDecision = {
allowed: boolean;
remaining: number;
resetAt: Date;
};
interface IRateLimit {
consume(
budgetName: string,
key: string,
weight?: number,
): Promise<RateLimitDecision>;
reset(budgetName: string, key: string): Promise<void>;
}
export type RecordedConsumeCall = {
budgetName: string;
key: string;
weight: number | undefined;
};
export type RecordedResetCall = {
budgetName: string;
key: string;
};
const EPOCH = new Date(0);
export class RecordingRateLimit implements IRateLimit {
readonly consumeCalls: RecordedConsumeCall[] = [];
readonly resetCalls: RecordedResetCall[] = [];
private _decision: RateLimitDecision = {
allowed: true,
remaining: Infinity,
resetAt: EPOCH,
};
withDecision(decision: RateLimitDecision): this {
this._decision = decision;
return this;
}
async consume(
budgetName: string,
key: string,
weight?: number,
): Promise<RateLimitDecision> {
this.consumeCalls.push({ budgetName, key, weight });
return this._decision;
}
async reset(budgetName: string, key: string): Promise<void> {
this.resetCalls.push({ budgetName, key });
}
}

View File

@@ -0,0 +1,5 @@
export {
renderWithProviders,
type RenderOptions,
} from "./render-with-providers";
export { createMockTrpcClient } from "./mock-trpc";

View File

@@ -0,0 +1,32 @@
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AnyTRPCRouter } from "@trpc/server";
// Returns a tRPC client whose fetch is a stub honouring the provided mocks.
// Mocks are keyed by procedure path ("blog.articleBySlug") returning the
// raw response body.
export function createMockTrpcClient<TRouter extends AnyTRPCRouter>(
mocks: Record<string, unknown> = {},
) {
const fetchStub: typeof fetch = async (input) => {
const url = typeof input === "string" ? input : (input as Request).url;
const path = new URL(url, "http://mock").pathname.replace(/^\/api\/trpc\//, "");
const result = mocks[path];
if (result === undefined) {
return new Response(JSON.stringify([{ error: { code: -32603, message: `No mock for ${path}` } }]), { status: 200 });
}
return new Response(JSON.stringify([{ result: { data: superjson.serialize(result) } }]), { status: 200 });
};
// httpBatchLink<TRouter> requires a conditional TransformerOptions<TRouter> that
// depends on whether the router uses a transformer; making this generic-friendly
// without parameterizing twice requires <any> here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const link = httpBatchLink<any>({
url: "http://mock/api/trpc",
transformer: superjson,
fetch: fetchStub,
});
return createTRPCClient<TRouter>({ links: [link] });
}

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "@/react/render-with-providers";
describe("renderWithProviders", () => {
it("renders the child", () => {
renderWithProviders(<div data-testid="x">hi</div>);
expect(screen.getByTestId("x")).toBeInTheDocument();
});
it("returns the queryClient instance", () => {
const { queryClient } = renderWithProviders(<div />);
expect(queryClient).toBeDefined();
});
});

View File

@@ -0,0 +1,35 @@
import type { PropsWithChildren, ReactElement } from "react";
import { render } from "@testing-library/react";
import type { RenderResult } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export interface RenderOptions {
queryClient?: QueryClient;
}
// Wraps the given UI with QueryClientProvider only.
// This helper intentionally omits a TRPCProvider. Adding one would require importing
// AppRouter from @repo/core-api, which violates the tooling→core-composition boundary rule.
// For components that need tRPC in the render tree, the consumer must:
// 1. Wire their own TRPCProvider (from their app's tRPC client setup).
// 2. Pass a client built with createMockTrpcClient as the tRPC client.
export function renderWithProviders(
ui: ReactElement,
options: RenderOptions = {},
): RenderResult & { queryClient: QueryClient } {
const queryClient =
options.queryClient ??
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const Wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const result = render(ui, { wrapper: Wrapper });
return Object.assign(result, { queryClient });
}

View File

@@ -0,0 +1,8 @@
import "./no-instrumentation";
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,20 @@
import { describe, it, expect, vi } from "vitest";
import * as Sentry from "@sentry/nextjs";
describe("setup/no-instrumentation guard", () => {
it("Sentry.init is a vi.fn (mocked, not real)", () => {
expect(vi.isMockFunction(Sentry.init)).toBe(true);
});
it("Sentry.captureException is a vi.fn", () => {
expect(vi.isMockFunction(Sentry.captureException)).toBe(true);
});
it("calling Sentry.init does not throw or initialize", () => {
expect(() =>
Sentry.init({ dsn: "https://x@y/1" } as Parameters<
typeof Sentry.init
>[0]),
).not.toThrow();
});
});

View File

@@ -0,0 +1,105 @@
import { vi } from "vitest";
/**
* Guard against real Sentry SDK + OTel SDK initialization in test processes.
*
* Mocks @sentry/* and key @opentelemetry/sdk-* modules at the module level so
* any code that imports them receives a no-op surface. Tests that need to assert
* specific SDK behavior still use vi.mock locally with their own implementation;
* this guard just ensures *unintentional* imports don't cause real network/init.
*
* Also exported as ./setup/no-sentry for one release cycle (backward-compat alias).
*/
vi.mock("@sentry/nextjs", () => ({
init: vi.fn(),
startSpan: vi.fn((_opts: unknown, fn: (span: unknown) => unknown) =>
fn({ setAttribute: vi.fn(), setStatus: vi.fn() }),
),
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
setContext: vi.fn(),
setTag: vi.fn(),
setExtra: vi.fn(),
withScope: vi.fn((fn: (scope: unknown) => unknown) =>
fn({ setTag: vi.fn(), setExtra: vi.fn() }),
),
replayIntegration: vi.fn(() => ({ name: "Replay" })),
getActiveSpan: vi.fn(() => undefined),
getCurrentHub: vi.fn(() => ({ getClient: () => undefined })),
}));
vi.mock("@sentry/node", () => ({
init: vi.fn(),
startSpan: vi.fn((_opts: unknown, fn: (span: unknown) => unknown) =>
fn({ setAttribute: vi.fn(), setStatus: vi.fn() }),
),
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
setContext: vi.fn(),
setTag: vi.fn(),
setExtra: vi.fn(),
withScope: vi.fn((fn: (scope: unknown) => unknown) =>
fn({ setTag: vi.fn(), setExtra: vi.fn() }),
),
getActiveSpan: vi.fn(() => undefined),
}));
vi.mock("@sentry/react", () => ({
init: vi.fn(),
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
setContext: vi.fn(),
setTag: vi.fn(),
setExtra: vi.fn(),
withScope: vi.fn((fn: (scope: unknown) => unknown) =>
fn({ setTag: vi.fn(), setExtra: vi.fn() }),
),
replayIntegration: vi.fn(() => ({ name: "Replay" })),
}));
// OTel SDK mocks — prevent real SDK initialization in vitest runs.
// Feature packages and core-shared instrumentation code import these; without
// mocks the NodeSDK would attempt to bootstrap a real tracer/logger provider.
vi.mock("@opentelemetry/sdk-node", () => ({
NodeSDK: class {
start() {}
shutdown() {
return Promise.resolve();
}
},
// Re-export tracing namespace so destructured imports work
tracing: {
BatchSpanProcessor: class {
onStart() {}
onEnd() {}
forceFlush() {
return Promise.resolve();
}
shutdown() {
return Promise.resolve();
}
},
},
}));
vi.mock("@sentry/opentelemetry", () => ({
SentrySpanProcessor: class {
onStart() {}
onEnd() {}
forceFlush() {
return Promise.resolve();
}
shutdown() {
return Promise.resolve();
}
},
// SentryLogRecordProcessor does NOT exist in @sentry/opentelemetry v10 — omitted.
// No-op Sentry.init wrapper used by sentry-bridge.ts
init: vi.fn(),
}));

View File

@@ -0,0 +1,5 @@
import "./no-instrumentation";
// Reserved for future global node-env setup. Currently a no-op so that
// vitest configs may reference @repo/core-testing/setup/node uniformly.
export {};

View File

@@ -0,0 +1,35 @@
{
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"_comment": "Shared Stryker base config for L3 mutation testing (ADR-020). Per-feature stryker.config.json files extend this. Edit a feature's config to widen scope; rarely needs editing here.",
"testRunner": "vitest",
"vitest": {
"configFile": "vitest.config.ts"
},
"mutate": [
"src/entities/**/*.ts",
"src/application/use-cases/**/*.ts",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/__factories__/**",
"!**/__contracts__/**"
],
"thresholds": {
"high": 90,
"low": 80,
"break": 80
},
"reporters": ["progress", "html", "json"],
"htmlReporter": {
"fileName": "reports/mutation/index.html"
},
"jsonReporter": {
"fileName": "reports/mutation/mutation.json"
},
"tempDirName": ".stryker-tmp",
"cleanTempDir": true,
"concurrency": 4,
"timeoutMS": 10000,
"logLevel": "info",
"incremental": true,
"incrementalFile": ".stryker-tmp/incremental.json"
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://turborepo.dev/schema.json",
"extends": ["//"],
"tags": ["tooling"]
}

View File

@@ -0,0 +1,18 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["./src/setup/jsdom.ts"],
clearMocks: true,
restoreMocks: true,
mockReset: true,
unstubGlobals: true,
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});