Initial commit
This commit is contained in:
96
packages/core-testing/AGENTS.md
Normal file
96
packages/core-testing/AGENTS.md
Normal 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.
|
||||
11
packages/core-testing/eslint.config.js
Normal file
11
packages/core-testing/eslint.config.js
Normal 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
60
packages/core-testing/package.json
Normal file
60
packages/core-testing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
27
packages/core-testing/src/contract/define-contract-suite.ts
Normal file
27
packages/core-testing/src/contract/define-contract-suite.ts
Normal 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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
5
packages/core-testing/src/contract/index.ts
Normal file
5
packages/core-testing/src/contract/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
defineContractSuite,
|
||||
type ContractContext,
|
||||
type ContractSuite,
|
||||
} from "./define-contract-suite";
|
||||
80
packages/core-testing/src/factory/define-factory.test.ts
Normal file
80
packages/core-testing/src/factory/define-factory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
packages/core-testing/src/factory/define-factory.ts
Normal file
49
packages/core-testing/src/factory/define-factory.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
5
packages/core-testing/src/factory/index.ts
Normal file
5
packages/core-testing/src/factory/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
defineFactory,
|
||||
type Factory,
|
||||
type FactoryContext,
|
||||
} from "./define-factory";
|
||||
4
packages/core-testing/src/index.ts
Normal file
4
packages/core-testing/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./factory/index";
|
||||
export * from "./contract/index";
|
||||
export * from "./instrumentation/index";
|
||||
export * from "./rate-limit/index";
|
||||
33
packages/core-testing/src/instrumentation/index.ts
Normal file
33
packages/core-testing/src/instrumentation/index.ts
Normal 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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
2
packages/core-testing/src/payload/index.ts
Normal file
2
packages/core-testing/src/payload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { stubPayloadConfig } from "./stub-config";
|
||||
export { mockPayloadModule } from "./mock-payload-module";
|
||||
27
packages/core-testing/src/payload/mock-payload-module.ts
Normal file
27
packages/core-testing/src/payload/mock-payload-module.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
6
packages/core-testing/src/payload/stub-config.ts
Normal file
6
packages/core-testing/src/payload/stub-config.ts
Normal 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;
|
||||
5
packages/core-testing/src/rate-limit/index.ts
Normal file
5
packages/core-testing/src/rate-limit/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
RecordingRateLimit,
|
||||
type RecordedConsumeCall,
|
||||
type RecordedResetCall,
|
||||
} from "./recording-rate-limit";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
60
packages/core-testing/src/rate-limit/recording-rate-limit.ts
Normal file
60
packages/core-testing/src/rate-limit/recording-rate-limit.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
5
packages/core-testing/src/react/index.ts
Normal file
5
packages/core-testing/src/react/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
renderWithProviders,
|
||||
type RenderOptions,
|
||||
} from "./render-with-providers";
|
||||
export { createMockTrpcClient } from "./mock-trpc";
|
||||
32
packages/core-testing/src/react/mock-trpc.ts
Normal file
32
packages/core-testing/src/react/mock-trpc.ts
Normal 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] });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
35
packages/core-testing/src/react/render-with-providers.tsx
Normal file
35
packages/core-testing/src/react/render-with-providers.tsx
Normal 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 });
|
||||
}
|
||||
8
packages/core-testing/src/setup/jsdom.ts
Normal file
8
packages/core-testing/src/setup/jsdom.ts
Normal 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();
|
||||
});
|
||||
20
packages/core-testing/src/setup/no-instrumentation.test.ts
Normal file
20
packages/core-testing/src/setup/no-instrumentation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
105
packages/core-testing/src/setup/no-instrumentation.ts
Normal file
105
packages/core-testing/src/setup/no-instrumentation.ts
Normal 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(),
|
||||
}));
|
||||
5
packages/core-testing/src/setup/node.ts
Normal file
5
packages/core-testing/src/setup/node.ts
Normal 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 {};
|
||||
35
packages/core-testing/stryker.base.json
Normal file
35
packages/core-testing/stryker.base.json
Normal 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"
|
||||
}
|
||||
12
packages/core-testing/tsconfig.json
Normal file
12
packages/core-testing/tsconfig.json
Normal 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"]
|
||||
}
|
||||
5
packages/core-testing/turbo.json
Normal file
5
packages/core-testing/turbo.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://turborepo.dev/schema.json",
|
||||
"extends": ["//"],
|
||||
"tags": ["tooling"]
|
||||
}
|
||||
18
packages/core-testing/vitest.config.ts
Normal file
18
packages/core-testing/vitest.config.ts
Normal 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") },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user