refactor: strip Lazar references from top-level docs + guides

This commit is contained in:
2026-05-13 09:57:19 +02:00
parent 17ae157365
commit 06da37f723
8 changed files with 271 additions and 189 deletions

View File

@@ -84,7 +84,7 @@ No other cross-package boundary deviations are permitted.
## Adding a Feature ## Adding a Feature
**Fast path — use the generator.** `pnpm turbo gen feature` scaffolds a Lazar-conformant package under `packages/<name>/` (single entity, single `getX` use case) matching the `navigation` reference shape. It emits package files, entities, use case + controller (with input/output schemas + presenter), mock + real repositories (real one is a Phase-1 stub), DI container, both binders (`bind-production` / `bind-dev-seed`), tRPC procedures + router with tests, contract suite, dev seed, and an empty `ui/` barrel all wired with the span + capture sandwich at bind time. **Fast path — use the generator.** `pnpm turbo gen feature` scaffolds a package under `packages/<name>/` (single entity, single `getX` use case) matching the `navigation` reference shape. It emits package files, entities, use case + controller (with input/output schemas + presenter), mock + real repositories, DI container, both binders (`bind-production` / `bind-dev-seed`), tRPC procedures + router with tests, contract suite, dev seed, and an empty `ui/` barrel all wired with the span + capture sandwich at bind time.
```bash ```bash
pnpm turbo gen feature # interactive pnpm turbo gen feature # interactive
@@ -93,7 +93,7 @@ pnpm turbo gen feature --args widgets Widget widgets # non-interactive: <name
The generator does NOT wire aggregators or emit Payload CMS templates / faker factories / multi-entity layouts. After running, hand-edit `apps/web-next/src/server/bind-production.ts`, `packages/core-api/src/root.ts`, and the two `package.json` files (the generator prints the exact checklist on success). See `docs/guides/scaffolding-a-feature.md` for the full reference. The generator does NOT wire aggregators or emit Payload CMS templates / faker factories / multi-entity layouts. After running, hand-edit `apps/web-next/src/server/bind-production.ts`, `packages/core-api/src/root.ts`, and the two `package.json` files (the generator prints the exact checklist on success). See `docs/guides/scaffolding-a-feature.md` for the full reference.
**Manual path.** When the generator's Phase-1 scope doesn't fit (multiple entities/use cases, custom layout, extending an existing feature), follow `docs/guides/adding-a-feature.md` a step-by-step walkthrough covering folder structure, Clean Architecture layers, Payload + tRPC integration, core wiring, and testing / lint validation. **Manual path.** When the generator's scope doesn't fit (multiple entities/use cases, custom layout, extending an existing feature), follow `docs/guides/adding-a-feature.md` a step-by-step walkthrough covering folder structure, Clean Architecture layers, Payload + tRPC integration, core wiring, and testing / lint validation.
--- ---
@@ -105,7 +105,7 @@ pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storyb
pnpm typecheck # Type-check all packages pnpm typecheck # Type-check all packages
pnpm lint # Lint all packages (ESLint boundaries enforced) pnpm lint # Lint all packages (ESLint boundaries enforced)
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries) pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
pnpm turbo gen feature # Scaffold a new Lazar-conformant feature package (see docs/guides/scaffolding-a-feature.md) pnpm turbo gen feature # Scaffold a new feature package (see docs/guides/scaffolding-a-feature.md)
pnpm turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/scaffolding/core-package-generator.md) pnpm turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/scaffolding/core-package-generator.md)
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/scaffolding/core-ui-component-generator.md) pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/scaffolding/core-ui-component-generator.md)
pnpm test # Run all unit + integration tests (Vitest) pnpm test # Run all unit + integration tests (Vitest)
@@ -125,10 +125,8 @@ pnpm test --filter @repo/blog # Only blog unit/integration tests
## Per-Package Conventions ## Per-Package Conventions
> These conventions reflect the post-Plan-8 (Lazar conformance) and post-Plan-9 (input/output unification) state.
> Canonical summary: `CLAUDE.md` § Key Conventions. > Canonical summary: `CLAUDE.md` § Key Conventions.
> Refactor logs: `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` (Plan 8) and `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (Plan 9). > Decision records: `docs/decisions/adr-012-feature-conventions.md` and `docs/decisions/adr-013-input-output-unification.md`.
> Decision records: `docs/decisions/adr-012-lazar-conformance.md` and `docs/decisions/adr-013-input-output-unification.md`.
### Source files use RELATIVE imports (not @/) ### Source files use RELATIVE imports (not @/)
@@ -189,7 +187,7 @@ TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files
} }
``` ```
### Use cases own input + output schemas (Plan 9, R1R5) ### Use cases own input + output schemas
Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning a misbehaving repository fails loudly at the layer that owns the contract. Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning a misbehaving repository fails loudly at the layer that owns the contract.
@@ -230,7 +228,7 @@ const useCase = getArticlesUseCase(repo);
const articles = await useCase({ status: "published" }); const articles = await useCase({ status: "published" });
``` ```
### Controllers receive `unknown` + presenter (Plan 9, R7R12) ### Controllers receive `unknown` + presenter
Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise<ReturnType<typeof presenter>>`. Identity is fine `return value` but the function form is always present so adding a transform later is a one-line edit. Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise<ReturnType<typeof presenter>>`. Identity is fine `return value` but the function form is always present so adding a transform later is a one-line edit.
@@ -271,7 +269,7 @@ bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
); );
``` ```
### Feature-scoped tRPC error mapping (Plan 9, R13R17) ### Feature-scoped tRPC error mapping
Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes. Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes.
@@ -292,7 +290,7 @@ export const blogProcedure = t.procedure.use(
The router then uses `blogProcedure.input(xInputSchema)` for every procedure schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`. The router then uses `blogProcedure.input(xInputSchema)` for every procedure schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`.
### Per-feature public-API surface (Plan 9, R18R21) ### Per-feature public-API surface
Each feature package exposes exactly these subpath exports: Each feature package exposes exactly these subpath exports:
@@ -407,7 +405,7 @@ See `docs/guides/conformance-quickref.md` for the canonical pattern; the generat
--- ---
### Cross-feature events and background jobs (Plan 10, ADR-015) ### Cross-feature events and background jobs (ADR-015)
Three rules: Three rules:
@@ -516,11 +514,11 @@ const wrappedCtrl = withSpan(
| Controller | `InputParseError` from `safeParse` failure (via `withCapture`) | Errors from use cases flag set, `withCapture` bails | | Controller | `InputParseError` from `safeParse` failure (via `withCapture`) | Errors from use cases flag set, `withCapture` bails |
| `defineErrorMiddleware` | Nothing maps domain TRPCError only | | | `defineErrorMiddleware` | Nothing maps domain TRPCError only | |
**Boundary rules (eslint-enforced, R40 + R52):** **Boundary rules (eslint-enforced):**
Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists: Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists:
- R40 (`@sentry/*`): `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}` - `@sentry/*`: `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}`
- R52 (`@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`): `**/instrumentation/otel/**` - `@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`: `**/instrumentation/otel/**`
The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`. The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`.

View File

@@ -76,7 +76,7 @@ See `docs/architecture/agent-first-workflow-and-conformance.md` for the full des
- **`@/` alias in tests** Test files (`*.test.ts`) use `@/` to import from `src/` - **`@/` alias in tests** Test files (`*.test.ts`) use `@/` to import from `src/`
- **`vitest.config.ts`** Every package must define `resolve.alias: { "@": path.resolve(__dirname, "./src") }` - **`vitest.config.ts`** Every package must define `resolve.alias: { "@": path.resolve(__dirname, "./src") }`
- **`tsconfig.json` rootDir** Set `"rootDir": "."` so TypeScript finds both `src/` and test files - **`tsconfig.json` rootDir** Set `"rootDir": "."` so TypeScript finds both `src/` and test files
- **Lazar-conformant file layout** Entities live at `entities/models/<x>.ts`; errors at `entities/errors/<domain>.ts` + `entities/errors/common.ts`; mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `payload-` prefix (`<x>.repository.ts`); interface filenames are dot-separated (`<x>.repository.interface.ts`) - **File layout convention** Entities live at `entities/models/<x>.ts`; errors at `entities/errors/<domain>.ts` + `entities/errors/common.ts`; mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `payload-` prefix (`<x>.repository.ts`); interface filenames are dot-separated (`<x>.repository.interface.ts`)
- **Factory-function use cases & controllers** Every use case and controller is `(deps) => async (input) => result`; each exports `export type I*UseCase = ReturnType<typeof xUseCase>` (and the analogous `I*Controller`); one controller per use case (no multi-method controllers) - **Factory-function use cases & controllers** Every use case and controller is `(deps) => async (input) => result`; each exports `export type I*UseCase = ReturnType<typeof xUseCase>` (and the analogous `I*Controller`); one controller per use case (no multi-method controllers)
- **DI uses `.toDynamicValue()` for factories** `bind<IXUseCase>(SYMBOL).toDynamicValue((ctx) => xUseCase(ctx.container.get(...)))`; mocks remain the default binding - **DI uses `.toDynamicValue()` for factories** `bind<IXUseCase>(SYMBOL).toDynamicValue((ctx) => xUseCase(ctx.container.get(...)))`; mocks remain the default binding
- **Tests inject mocks directly** Construct `MockXRepository` and pass into the factory: `signInUseCase(mockUsers, mockAuth)(input)`. No container rebinding in unit tests - **Tests inject mocks directly** Construct `MockXRepository` and pass into the factory: `signInUseCase(mockUsers, mockAuth)(input)`. No container rebinding in unit tests
@@ -88,10 +88,10 @@ See `docs/architecture/agent-first-workflow-and-conformance.md` for the full des
- **Three binding modes per feature** Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` dev seed; `NODE_ENV="production"` production; otherwise dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory - **Three binding modes per feature** Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` dev seed; `NODE_ENV="production"` production; otherwise dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
- **Binders take a `ctx` arg from `core-shared/di`** `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders - **Binders take a `ctx` arg from `core-shared/di`** `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders
- **App bootstrap** Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent - **App bootstrap** Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent
- **Instrumentation lives in `core-shared/instrumentation/`** Three interfaces (`ITracer`, `ILogger`, `IMetrics`), three implementation pairs (`Noop*`, `Otel*`, and `Recording*` from `core-testing`). The OTel SDK is the substrate; Sentry is wired as the exporter via `@sentry/opentelemetry`. Feature packages MUST NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (R40 + R52, ESLint-enforced); the vendor-neutral `@opentelemetry/api` family is the import surface for advanced cases (ADR-017) - **Instrumentation lives in `core-shared/instrumentation/`** Three interfaces (`ITracer`, `ILogger`, `IMetrics`), three implementation pairs (`Noop*`, `Otel*`, and `Recording*` from `core-testing`). The OTel SDK is the substrate; Sentry is wired as the exporter via `@sentry/opentelemetry`. Feature packages MUST NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (ESLint-enforced); the vendor-neutral `@opentelemetry/api` family is the import surface for advanced cases (ADR-017)
- **Spans + capture composed at DI bind time** Use cases + controllers wrapped via `withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps)))` inside `bind-production` / `bind-dev-seed`. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes (R41, R42) - **Spans + capture composed at DI bind time** Use cases + controllers wrapped via `withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps)))` inside `bind-production` / `bind-dev-seed`. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes
- **Capture at throw sites only, with double-report guard** Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures (R43, R44). Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `OtelLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`) - **Capture at throw sites only, with double-report guard** Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures. Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `OtelLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`)
- **PII handling is non-negotiable** `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `setUser({ id })` only no email/username (R36); server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (R32, R33, ADR-017 §7) - **PII handling is non-negotiable** `sendDefaultPii: false` everywhere (CI grep gate); replay default-masks all text/inputs/media (allowlist starts empty); `setUser({ id })` only no email/username; server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (ADR-017 §7)
- **Three apps, three Sentry projects** `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes - **Three apps, three Sentry projects** `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes
- **Instrumentation binding is orthogonal to repo binding** `bindAll()`'s Rule 0 (DSN OTel+Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally - **Instrumentation binding is orthogonal to repo binding** `bindAll()`'s Rule 0 (DSN OTel+Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally
- **Cross-feature events go through `IEventBus` (E0)** In-feature reactions are direct use-case calls, not bus publishes. The bus is for _crossing_ feature boundaries (e.g. `auth` `marketing-pages` welcome email) - **Cross-feature events go through `IEventBus` (E0)** In-feature reactions are direct use-case calls, not bus publishes. The bus is for _crossing_ feature boundaries (e.g. `auth` `marketing-pages` welcome email)

View File

@@ -35,7 +35,7 @@ docker compose up -d # Start PostgreSQL
## Scaffolding ## Scaffolding
```bash ```bash
pnpm turbo gen feature <name> # Lazar-conformant feature (manifest + contracts + tests) pnpm turbo gen feature <name> # Scaffold a feature (manifest + contracts + tests)
pnpm turbo gen event # Event contract or handler (requires gen core-package events) pnpm turbo gen event # Event contract or handler (requires gen core-package events)
pnpm turbo gen job # Background job pnpm turbo gen job # Background job
pnpm turbo gen realtime # Realtime channel (requires gen core-package realtime) pnpm turbo gen realtime # Realtime channel (requires gen core-package realtime)

View File

@@ -5,7 +5,7 @@ tRPC router, CMS collection, DI container, and query builders — all owned
by one package under `packages/<feature>/`. by one package under `packages/<feature>/`.
> **Prefer the generator.** `pnpm turbo gen feature` produces a > **Prefer the generator.** `pnpm turbo gen feature` produces a
> Lazar-conformant single-entity / single-use-case package matching the > A single-entity / single-use-case package matching the
> `navigation` reference shape (DI, tRPC router with tests, span + capture > `navigation` reference shape (DI, tRPC router with tests, span + capture
> sandwich, dev seed, contract suite). See > sandwich, dev seed, contract suite). See
> [Scaffolding a Feature](./scaffolding-a-feature.md). Use this guide when > [Scaffolding a Feature](./scaffolding-a-feature.md). Use this guide when
@@ -44,20 +44,20 @@ For the fast path, run `pnpm turbo gen feature <name>` — the generator emits t
Every feature package owns: Every feature package owns:
| Layer | What lives there | | Layer | What lives there |
|---|---| | --------------------------------- | -------------------------------------------------------------------------------------------- |
| `entities/models/` | Zod schemas + inferred TypeScript types | | `entities/models/` | Zod schemas + inferred TypeScript types |
| `entities/errors/` | Domain error classes (`this.name` required); `common.ts` for `InputParseError` | | `entities/errors/` | Domain error classes (`this.name` required); `common.ts` for `InputParseError` |
| `application/repositories/` | Repository interface (no implementation) | | `application/repositories/` | Repository interface (no implementation) |
| `application/use-cases/` | One factory per operation; owns `xInputSchema`, `xOutputSchema`, and `xOutputSchema.parse()` | | `application/use-cases/` | One factory per operation; owns `xInputSchema`, `xOutputSchema`, and `xOutputSchema.parse()` |
| `infrastructure/repositories/` | Real (`<noun>.repository.ts`) and mock (`<noun>.repository.mock.ts`) siblings | | `infrastructure/repositories/` | Real (`<noun>.repository.ts`) and mock (`<noun>.repository.mock.ts`) siblings |
| `interface-adapters/controllers/` | One factory per use case; accepts `unknown`, calls `safeParse`, runs presenter | | `interface-adapters/controllers/` | One factory per use case; accepts `unknown`, calls `safeParse`, runs presenter |
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` | | `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) | | `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
| `integrations/cms/` | Payload collection/global configs | | `integrations/cms/` | Payload collection/global configs |
| `ui/` | Query builders and future React components (behind `./ui` subpath) | | `ui/` | Query builders and future React components (behind `./ui` subpath) |
| `__factories__/` | Test data factories | | `__factories__/` | Test data factories |
| `__contracts__/` | Contract suites shared by mock and real repository tests | | `__contracts__/` | Contract suites shared by mock and real repository tests |
The walkthrough below builds a minimal `comments` feature from scratch. The walkthrough below builds a minimal `comments` feature from scratch.
All concrete code mirrors the `blog` package (the most fully developed All concrete code mirrors the `blog` package (the most fully developed
@@ -108,7 +108,7 @@ packages/comments/
api/ api/
procedures.ts # commentsProcedure with feature error map procedures.ts # commentsProcedure with feature error map
router.ts # commentsProcedure.input(xInputSchema) router.ts # commentsProcedure.input(xInputSchema)
router.test.ts # includes R26 error-mapping assertions router.test.ts # includes error-mapping assertions
index.ts index.ts
cms/ cms/
collections/ collections/
@@ -245,7 +245,13 @@ describe("commentSchema", () => {
it("rejects an empty body", () => { it("rejects an empty body", () => {
expect(() => expect(() =>
commentSchema.parse({ id: "c-1", articleId: "a-1", body: "", authorId: "u-1", createdAt: new Date() }), commentSchema.parse({
id: "c-1",
articleId: "a-1",
body: "",
authorId: "u-1",
createdAt: new Date(),
}),
).toThrow(); ).toThrow();
}); });
}); });
@@ -285,7 +291,7 @@ pnpm test --filter @repo/comments -- comment.test.ts # GREEN
export class CommentNotFoundError extends Error { export class CommentNotFoundError extends Error {
constructor(message = "Comment not found", options?: ErrorOptions) { constructor(message = "Comment not found", options?: ErrorOptions) {
super(message, options); super(message, options);
this.name = "CommentNotFoundError"; // required — R6 this.name = "CommentNotFoundError"; // required — R6
} }
} }
``` ```
@@ -295,7 +301,7 @@ export class CommentNotFoundError extends Error {
export class InputParseError extends Error { export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) { constructor(message: string, options?: ErrorOptions) {
super(message, options); super(message, options);
this.name = "InputParseError"; // required — R6 this.name = "InputParseError"; // required — R6
} }
} }
``` ```
@@ -364,6 +370,7 @@ export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
### Step 9: Use case — factory function with input/output schemas (RED → GREEN) ### Step 9: Use case — factory function with input/output schemas (RED → GREEN)
Every use case exports: Every use case exports:
- `xInputSchema` — a `z.ZodObject` with `.strict()` (use `z.object({}).strict()` for void inputs) - `xInputSchema` — a `z.ZodObject` with `.strict()` (use `z.object({}).strict()` for void inputs)
- `xOutputSchema` — for non-void use cases - `xOutputSchema` — for non-void use cases
- `XInput` / `XOutput` types - `XInput` / `XOutput` types
@@ -404,14 +411,16 @@ describe("getCommentsUseCase", () => {
}); });
}); });
// R25 — output validation // output validation
describe("getCommentsUseCase output validation (R25)", () => { describe("getCommentsUseCase output validation", () => {
it("throws ZodError when the repository returns malformed data", async () => { it("throws ZodError when the repository returns malformed data", async () => {
const repo = new MockCommentsRepository(); const repo = new MockCommentsRepository();
(repo as unknown as { _comments: unknown[] })._comments.push({ id: 123 }); (repo as unknown as { _comments: unknown[] })._comments.push({ id: 123 });
const useCase = getCommentsUseCase(repo); const useCase = getCommentsUseCase(repo);
await expect(useCase({ articleId: "a-1" })).rejects.toBeInstanceOf(ZodError); await expect(useCase({ articleId: "a-1" })).rejects.toBeInstanceOf(
ZodError,
);
}); });
it("exports getCommentsOutputSchema that validates Comment[]", () => { it("exports getCommentsOutputSchema that validates Comment[]", () => {
@@ -448,7 +457,9 @@ export type IGetCommentsUseCase = ReturnType<typeof getCommentsUseCase>;
export const getCommentsUseCase = export const getCommentsUseCase =
(commentsRepository: ICommentsRepository) => (commentsRepository: ICommentsRepository) =>
async (input: GetCommentsInput): Promise<GetCommentsOutput> => { async (input: GetCommentsInput): Promise<GetCommentsOutput> => {
const result = await commentsRepository.getCommentsForArticle(input.articleId); const result = await commentsRepository.getCommentsForArticle(
input.articleId,
);
return getCommentsOutputSchema.parse(result); return getCommentsOutputSchema.parse(result);
}; };
``` ```
@@ -586,7 +597,9 @@ describe("getCommentsController", () => {
it("throws InputParseError on unknown extra fields (strict)", async () => { it("throws InputParseError on unknown extra fields (strict)", async () => {
const repo = new MockCommentsRepository(); const repo = new MockCommentsRepository();
const ctrl = getCommentsController(getCommentsUseCase(repo)); const ctrl = getCommentsController(getCommentsUseCase(repo));
await expect(ctrl({ articleId: "a-1", extra: true })).rejects.toBeInstanceOf(InputParseError); await expect(
ctrl({ articleId: "a-1", extra: true }),
).rejects.toBeInstanceOf(InputParseError);
}); });
}); });
``` ```
@@ -617,7 +630,9 @@ export const getCommentsController =
async (input: unknown): Promise<ReturnType<typeof presenter>> => { async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getCommentsInputSchema.safeParse(input); const parsed = getCommentsInputSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
throw new InputParseError("Invalid get-comments input", { cause: parsed.error }); throw new InputParseError("Invalid get-comments input", {
cause: parsed.error,
});
} }
const result = await getCommentsUseCase(parsed.data); const result = await getCommentsUseCase(parsed.data);
return presenter(result); return presenter(result);
@@ -659,17 +674,27 @@ import {
import { COMMENTS_SYMBOLS } from "./symbols"; import { COMMENTS_SYMBOLS } from "./symbols";
export const CommentsModule = new ContainerModule((bind: interfaces.Bind) => { export const CommentsModule = new ContainerModule((bind: interfaces.Bind) => {
bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(MockCommentsRepository); bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(
MockCommentsRepository,
);
bind<IGetCommentsUseCase>(COMMENTS_SYMBOLS.IGetCommentsUseCase).toDynamicValue((ctx) => bind<IGetCommentsUseCase>(
COMMENTS_SYMBOLS.IGetCommentsUseCase,
).toDynamicValue((ctx) =>
getCommentsUseCase( getCommentsUseCase(
ctx.container.get<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository), ctx.container.get<ICommentsRepository>(
COMMENTS_SYMBOLS.ICommentsRepository,
),
), ),
); );
bind<IGetCommentsController>(COMMENTS_SYMBOLS.IGetCommentsController).toDynamicValue((ctx) => bind<IGetCommentsController>(
COMMENTS_SYMBOLS.IGetCommentsController,
).toDynamicValue((ctx) =>
getCommentsController( getCommentsController(
ctx.container.get<IGetCommentsUseCase>(COMMENTS_SYMBOLS.IGetCommentsUseCase), ctx.container.get<IGetCommentsUseCase>(
COMMENTS_SYMBOLS.IGetCommentsUseCase,
),
), ),
); );
}); });
@@ -695,15 +720,21 @@ import { COMMENTS_SYMBOLS } from "@/di/symbols";
describe("commentsContainer", () => { describe("commentsContainer", () => {
it("resolves ICommentsRepository", () => { it("resolves ICommentsRepository", () => {
expect(commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository)).toBeDefined(); expect(
commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository),
).toBeDefined();
}); });
it("resolves IGetCommentsUseCase", () => { it("resolves IGetCommentsUseCase", () => {
expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase)).toBeDefined(); expect(
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase),
).toBeDefined();
}); });
it("resolves IGetCommentsController", () => { it("resolves IGetCommentsController", () => {
expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController)).toBeDefined(); expect(
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController),
).toBeDefined();
}); });
}); });
``` ```
@@ -733,9 +764,10 @@ export const commentsProcedure = t.procedure.use(
--- ---
### Step 14: tRPC router (RED → GREEN, includes R26 error-mapping test) ### Step 14: tRPC router (RED → GREEN, includes error-mapping test)
The router: The router:
- uses `commentsProcedure` (never bare `publicProcedure`) - uses `commentsProcedure` (never bare `publicProcedure`)
- calls `.input(xInputSchema)` importing from the use-case file — never redefines the schema inline - calls `.input(xInputSchema)` importing from the use-case file — never redefines the schema inline
- resolves controllers from the container - resolves controllers from the container
@@ -758,7 +790,9 @@ describe("commentsRouter", () => {
}); });
it("exposes getComments procedure", () => { it("exposes getComments procedure", () => {
expect(Object.keys(commentsRouter._def.procedures)).toContain("getComments"); expect(Object.keys(commentsRouter._def.procedures)).toContain(
"getComments",
);
}); });
it("getComments returns empty array by default", async () => { it("getComments returns empty array by default", async () => {
@@ -767,8 +801,8 @@ describe("commentsRouter", () => {
}); });
}); });
// R26 — error mapping // error mapping
describe("commentsRouter (R26 error mapping)", () => { describe("commentsRouter error mapping", () => {
beforeEach(() => { beforeEach(() => {
commentsContainer.unbindAll(); commentsContainer.unbindAll();
commentsContainer.load(CommentsModule); commentsContainer.load(CommentsModule);
@@ -871,7 +905,11 @@ export class CommentsRepository implements ICommentsRepository {
async getComment(id: string): Promise<Comment | undefined> { async getComment(id: string): Promise<Comment | undefined> {
const payload = await getPayload({ config: this.config }); const payload = await getPayload({ config: this.config });
try { try {
const doc = await payload.findByID({ collection: "comments", id, overrideAccess: true }); const doc = await payload.findByID({
collection: "comments",
id,
overrideAccess: true,
});
return mapDoc(doc as PayloadCommentDoc); return mapDoc(doc as PayloadCommentDoc);
} catch { } catch {
return undefined; return undefined;
@@ -892,7 +930,11 @@ export class CommentsRepository implements ICommentsRepository {
const payload = await getPayload({ config: this.config }); const payload = await getPayload({ config: this.config });
const created = await payload.create({ const created = await payload.create({
collection: "comments", collection: "comments",
data: { articleId: input.articleId, body: input.body, author: input.authorId } as never, data: {
articleId: input.articleId,
body: input.body,
author: input.authorId,
} as never,
overrideAccess: true, overrideAccess: true,
}); });
return mapDoc(created as PayloadCommentDoc); return mapDoc(created as PayloadCommentDoc);
@@ -917,13 +959,17 @@ describe("CommentsRepository", () => {
const store = new Map<string, Record<string, unknown>>(); const store = new Map<string, Record<string, unknown>>();
const stub = { const stub = {
findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)), findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)),
find: vi.fn(async ({ where }: { where?: { articleId?: { equals: string } } }) => { find: vi.fn(
let docs = Array.from(store.values()); async ({ where }: { where?: { articleId?: { equals: string } } }) => {
if (where?.articleId) { let docs = Array.from(store.values());
docs = docs.filter((d) => d["articleId"] === where.articleId?.equals); if (where?.articleId) {
} docs = docs.filter(
return { docs }; (d) => d["articleId"] === where.articleId?.equals,
}), );
}
return { docs };
},
),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => { create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const doc = { id: `stub-${store.size + 1}`, ...data }; const doc = { id: `stub-${store.size + 1}`, ...data };
store.set(String(doc.id), doc); store.set(String(doc.id), doc);
@@ -996,7 +1042,12 @@ export const comments: CollectionConfig = {
fields: [ fields: [
{ name: "articleId", type: "text", required: true }, { name: "articleId", type: "text", required: true },
{ name: "body", type: "textarea", required: true }, { name: "body", type: "textarea", required: true },
{ name: "author", type: "relationship", relationTo: "users", required: true }, {
name: "author",
type: "relationship",
relationTo: "users",
required: true,
},
], ],
}; };
``` ```
@@ -1089,7 +1140,9 @@ Add path aliases to `tsconfig.base.json`:
"@repo/comments/api": ["packages/comments/src/integrations/api/index.ts"], "@repo/comments/api": ["packages/comments/src/integrations/api/index.ts"],
"@repo/comments/ui": ["packages/comments/src/ui/index.ts"], "@repo/comments/ui": ["packages/comments/src/ui/index.ts"],
"@repo/comments/cms": ["packages/comments/src/integrations/cms/index.ts"], "@repo/comments/cms": ["packages/comments/src/integrations/cms/index.ts"],
"@repo/comments/di/bind-production": ["packages/comments/src/di/bind-production.ts"] "@repo/comments/di/bind-production": [
"packages/comments/src/di/bind-production.ts"
]
} }
} }
} }
@@ -1131,14 +1184,14 @@ All must pass before shipping.
## 4. Configuration Checklist ## 4. Configuration Checklist
| File | Key items | | File | Key items |
|---|---| | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `package.json` | `"type": "module"`; exports map with `.`, `./ui`, `./api`, `./cms`, `./di/bind-production`; `@repo/core-shared`, `inversify`, `zod`, `payload` in deps | | `package.json` | `"type": "module"`; exports map with `.`, `./ui`, `./api`, `./cms`, `./di/bind-production`; `@repo/core-shared`, `inversify`, `zod`, `payload` in deps |
| `tsconfig.json` | `"rootDir": "."` (covers both `src/` and `tests/`); `"outDir": "dist"` | | `tsconfig.json` | `"rootDir": "."` (covers both `src/` and `tests/`); `"outDir": "dist"` |
| `vitest.config.ts` | `resolve.alias: { "@": path.resolve(__dirname, "./src") }` | | `vitest.config.ts` | `resolve.alias: { "@": path.resolve(__dirname, "./src") }` |
| `eslint.config.js` | extends `@repo/core-eslint`; tag set to `"feature"` in Turborepo `turbo.json` | | `eslint.config.js` | extends `@repo/core-eslint`; tag set to `"feature"` in Turborepo `turbo.json` |
| `tsconfig.base.json` | path aliases for every subpath export | | `tsconfig.base.json` | path aliases for every subpath export |
| `turbo.json` | feature package must appear (or be glob-matched) in the workspace graph | | `turbo.json` | feature package must appear (or be glob-matched) in the workspace graph |
--- ---
@@ -1168,7 +1221,7 @@ All must pass before shipping.
`Promise<ReturnType<typeof presenter>>`. Identity (`return value`) is `Promise<ReturnType<typeof presenter>>`. Identity (`return value`) is
fine, but the function must exist. Skipping it makes adding a view fine, but the function must exist. Skipping it makes adding a view
transform later a structural change instead of a one-line edit transform later a structural change instead of a one-line edit
(ADR-013 R11). (ADR-013).
5. **Adding feature error classes to `core-shared`.** `core-shared` must 5. **Adding feature error classes to `core-shared`.** `core-shared` must
stay boundary-clean — it provides `defineErrorMiddleware` but knows stay boundary-clean — it provides `defineErrorMiddleware` but knows
@@ -1194,22 +1247,16 @@ All must pass before shipping.
## 6. Cross-References ## 6. Cross-References
- **ADR-012** (`docs/decisions/adr-012-lazar-conformance.md`) — factory-function - **ADR-012** (`docs/decisions/adr-012-feature-conventions.md`) — factory-function
use cases and controllers, entity layout, file naming, one-controller-per-use-case, use cases and controllers, entity layout, file naming, one-controller-per-use-case,
`.toDynamicValue()` DI bindings, direct injection in tests. `.toDynamicValue()` DI bindings, direct injection in tests.
- **ADR-013** (`docs/decisions/adr-013-input-output-unification.md`) — use-case - **ADR-013** (`docs/decisions/adr-013-input-output-unification.md`) — use-case
file as single source for `xInputSchema` + `xOutputSchema`; presenter pattern; file as single source for `xInputSchema` + `xOutputSchema`; presenter pattern;
per-feature `procedures.ts` error map; public surface split (`./` vs `./ui`). per-feature `procedures.ts` error map; public surface split (`./` vs `./ui`).
- **Refactor log — Plan 8** (`docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`) —
file-by-file inventory of every rename, split, and pattern change applied
to all existing features.
- **Refactor log — Plan 9** (`docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`) —
inventory of schema additions, presenter additions, `procedures.ts` additions,
and `./ui` subpath additions across all 5 features.
- **CLAUDE.md** (root) — Key Conventions section is the quick-reference - **CLAUDE.md** (root) — Key Conventions section is the quick-reference
summary; this guide is the authoritative walkthrough. summary; this guide is the authoritative walkthrough.
- **Architecture overview** (`docs/architecture/overview.md`) — canonical - **Architecture overview** (`docs/architecture/overview.md`) — canonical
data-flow diagram showing the full request path from React component data-flow diagram showing the full request path from React component
through tRPC, controller, use case, repository, and back. through tRPC, controller, use case, repository, and back.
- **TDD Workflow** (`docs/guides/tdd-workflow.md`) — required reading on - **TDD Workflow** (`docs/guides/tdd-workflow.md`) — required reading on
RED → GREEN discipline, direct factory injection, and R25/R26 test obligations. RED → GREEN discipline, direct factory injection, and test obligations per layer.

View File

@@ -165,7 +165,7 @@ This emits:
- `packages/<name>/src/feature.manifest.ts` — the conformance manifest (use cases, audits, publishes, consumes) - `packages/<name>/src/feature.manifest.ts` — the conformance manifest (use cases, audits, publishes, consumes)
- `packages/<name>/src/di/bind-production.ts` with `assertFeatureConformance(...)` at the tail (refuses to boot on drift) - `packages/<name>/src/di/bind-production.ts` with `assertFeatureConformance(...)` at the tail (refuses to boot on drift)
- Mock repository, factory, seed, entity, use-case, controller, tests — full Lazar-conformant shape - Mock repository, factory, seed, entity, use-case, controller, tests — full canonical shape
- `packages/<name>/src/index.ts` exports - `packages/<name>/src/index.ts` exports
After scaffolding, the four-step ordering for any new use case: After scaffolding, the four-step ordering for any new use case:

View File

@@ -1,6 +1,6 @@
# Scaffolding a feature # Scaffolding a feature
`turbo gen feature` produces a Lazar-conformant feature package under `turbo gen feature` produces a feature package under
`packages/<name>/` matching the shape of the reference `navigation` feature. `packages/<name>/` matching the shape of the reference `navigation` feature.
## Invoking the generator ## Invoking the generator
@@ -18,11 +18,11 @@ Non-interactive (positional bypass — order matches the prompts in
pnpm turbo gen feature --args <name> <Entity> <entities-plural> pnpm turbo gen feature --args <name> <Entity> <entities-plural>
``` ```
| Position | Prompt | Example | Conventions | | Position | Prompt | Example | Conventions |
|---|---|---|---| | ------------------- | -------------------- | --------- | ----------------------------------------------------------- |
| `<name>` | Feature package name | `widgets` | `kebab-case`, becomes `@repo/<name>` and `packages/<name>/` | | `<name>` | Feature package name | `widgets` | `kebab-case`, becomes `@repo/<name>` and `packages/<name>/` |
| `<Entity>` | Entity name | `Widget` | `PascalCase` singular, drives class/symbol/use-case names | | `<Entity>` | Entity name | `Widget` | `PascalCase` singular, drives class/symbol/use-case names |
| `<entities-plural>` | Entity plural slug | `widgets` | `kebab-case`, used for the future Payload collection slug | | `<entities-plural>` | Entity plural slug | `widgets` | `kebab-case`, used for the future Payload collection slug |
Example end-to-end: Example end-to-end:
@@ -60,7 +60,7 @@ See `docs/guides/conformance-quickref.md` for the manifest field reference.
- DI: `symbols.ts`, `module.ts`, `container.ts`, plus - DI: `symbols.ts`, `module.ts`, `container.ts`, plus
`bind-production.ts` and `bind-dev-seed.ts` that compose `bind-production.ts` and `bind-dev-seed.ts` that compose
`withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` at `withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` at
bind time (post-R44 sandwich pattern) bind time (span + capture sandwich pattern)
- tRPC integration: `procedures.ts` (feature-scoped error middleware) and - tRPC integration: `procedures.ts` (feature-scoped error middleware) and
`router.ts` exposing `get<Entity>` with full router tests including `router.ts` exposing `get<Entity>` with full router tests including
`BAD_REQUEST` / `NOT_FOUND` mapping `BAD_REQUEST` / `NOT_FOUND` mapping
@@ -123,8 +123,8 @@ The realtime generators insert at three additional fixed `// <gen:realtime-*>` a
schemas-in-use-case, three binding modes per feature, span + capture sandwich) schemas-in-use-case, three binding modes per feature, span + capture sandwich)
- `packages/navigation/AGENTS.md` — canonical reference shape the templates mirror - `packages/navigation/AGENTS.md` — canonical reference shape the templates mirror
- `docs/architecture/vertical-feature-spec.md` — design rationale for the layout - `docs/architecture/vertical-feature-spec.md` — design rationale for the layout
- `docs/decisions/adr-012-lazar-conformance.md` — file naming + factory pattern - `docs/decisions/adr-012-feature-conventions.md` — file naming + factory pattern
- `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter - `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter
- `docs/decisions/adr-014-instrumentation-sentry.md` — span + capture wiring (R41R44) - `docs/decisions/adr-014-instrumentation-sentry.md` — span + capture wiring
- `docs/decisions/adr-015-events-and-jobs.md` — cross-feature events + background jobs - `docs/decisions/adr-015-events-and-jobs.md` — cross-feature events + background jobs
- `docs/decisions/adr-016-realtime-layer.md` — Socket.IO realtime channels + handlers - `docs/decisions/adr-016-realtime-layer.md` — Socket.IO realtime channels + handlers

View File

@@ -24,7 +24,9 @@ describe("getArticleBySlugController", () => {
it("returns the article when the slug exists", async () => { it("returns the article when the slug exists", async () => {
const repo = new MockArticlesRepository(); const repo = new MockArticlesRepository();
articleFactory.reset(); articleFactory.reset();
await repo.createArticle(articleFactory.build({ slug: "hello-world", authorId: "u1" })); await repo.createArticle(
articleFactory.build({ slug: "hello-world", authorId: "u1" }),
);
const useCase = getArticleBySlugUseCase(repo); const useCase = getArticleBySlugUseCase(repo);
const controller = getArticleBySlugController(useCase); const controller = getArticleBySlugController(useCase);
@@ -38,9 +40,9 @@ describe("getArticleBySlugController", () => {
const useCase = getArticleBySlugUseCase(repo); const useCase = getArticleBySlugUseCase(repo);
const controller = getArticleBySlugController(useCase); const controller = getArticleBySlugController(useCase);
await expect( await expect(controller({ slug: "no-such-slug" })).rejects.toBeInstanceOf(
controller({ slug: "no-such-slug" }), ArticleNotFoundError,
).rejects.toBeInstanceOf(ArticleNotFoundError); );
}); });
}); });
``` ```
@@ -60,23 +62,31 @@ pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
```typescript ```typescript
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts // packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts
import type { IGetArticleBySlugUseCase, GetArticleBySlugOutput } from import type {
"../application/use-cases/get-article-by-slug.use-case"; IGetArticleBySlugUseCase,
GetArticleBySlugOutput,
} from "../application/use-cases/get-article-by-slug.use-case";
import { getArticleBySlugInputSchema } from "../application/use-cases/get-article-by-slug.use-case"; import { getArticleBySlugInputSchema } from "../application/use-cases/get-article-by-slug.use-case";
import { InputParseError } from "../entities/errors/common"; import { InputParseError } from "../entities/errors/common";
function presenter(value: GetArticleBySlugOutput) { return value; } function presenter(value: GetArticleBySlugOutput) {
return value;
}
export function getArticleBySlugController(useCase: IGetArticleBySlugUseCase) { export function getArticleBySlugController(useCase: IGetArticleBySlugUseCase) {
return async (input: unknown): Promise<ReturnType<typeof presenter>> => { return async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticleBySlugInputSchema.safeParse(input); const parsed = getArticleBySlugInputSchema.safeParse(input);
if (!parsed.success) if (!parsed.success)
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error }); throw new InputParseError("Invalid get-article-by-slug input", {
cause: parsed.error,
});
return presenter(await useCase(parsed.data)); return presenter(await useCase(parsed.data));
}; };
} }
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>; export type IGetArticleBySlugController = ReturnType<
typeof getArticleBySlugController
>;
``` ```
**Run again — confirm GREEN:** **Run again — confirm GREEN:**
@@ -140,6 +150,7 @@ describe("getArticleBySlugController", () => {
``` ```
Rules: Rules:
- `describe` names the class or function under test — not the file. - `describe` names the class or function under test — not the file.
- `it` uses active voice: `returns`, `throws`, `filters`, `creates`. - `it` uses active voice: `returns`, `throws`, `filters`, `creates`.
- Conditions go after `when`: `it("returns undefined when slug is missing")`. - Conditions go after `when`: `it("returns undefined when slug is missing")`.
@@ -232,15 +243,15 @@ The rule: mock the thing your layer depends on, never the thing under test.
## 5. Test Pyramid for This Monorepo ## 5. Test Pyramid for This Monorepo
| Layer | Tool | Target ratio | Location pattern | | Layer | Tool | Target ratio | Location pattern |
|---|---|---|---| | ---------------------------- | ----------------------- | ---------------------------- | ---------------------------------------------- |
| Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/models/*.test.ts` | | Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/models/*.test.ts` |
| Use case (business logic) | Vitest + mock repo | High — every use case | `src/application/use-cases/*.test.ts` | | Use case (business logic) | Vitest + mock repo | High — every use case | `src/application/use-cases/*.test.ts` |
| Controller (input parsing) | Vitest + mock repo | High — every controller | `src/interface-adapters/controllers/*.test.ts` | | Controller (input parsing) | Vitest + mock repo | High — every controller | `src/interface-adapters/controllers/*.test.ts` |
| Repository contract | Vitest + contract suite | One per impl | `src/infrastructure/repositories/*.test.ts` | | Repository contract | Vitest + contract suite | One per impl | `src/infrastructure/repositories/*.test.ts` |
| Feature integration (tRPC) | Vitest + createCaller | Medium — happy path + error | `src/integrations/api/router.test.ts` | | Feature integration (tRPC) | Vitest + createCaller | Medium — happy path + error | `src/integrations/api/router.test.ts` |
| Component | Vitest + RTL | Per UI component | `src/ui/**/*.test.tsx` | | Component | Vitest + RTL | Per UI component | `src/ui/**/*.test.tsx` |
| E2E | Playwright | Few — smoke + critical flows | `apps/web-next/e2e/*.spec.ts` | | E2E | Playwright | Few — smoke + critical flows | `apps/web-next/e2e/*.spec.ts` |
Entities and use cases have the highest ratio because they encode business rules. E2E tests have the lowest ratio because they are slow and test the full stack. Entities and use cases have the highest ratio because they encode business rules. E2E tests have the lowest ratio because they are slow and test the full stack.
@@ -260,13 +271,13 @@ Entities and use cases have the highest ratio because they encode business rules
## 7. Coverage Targets ## 7. Coverage Targets
| Scope | Statements | Branches | Functions | Lines | | Scope | Statements | Branches | Functions | Lines |
|---|---|---|---|---| | ----------------------- | ---------- | -------- | --------- | ----- |
| Baseline (all packages) | 80% | 75% | 80% | 80% | | Baseline (all packages) | 80% | 75% | 80% | 80% |
| Entities | 100% | 100% | 100% | 100% | | Entities | 100% | 100% | 100% | 100% |
| Use cases | 100% | 100% | 100% | 100% | | Use cases | 100% | 100% | 100% | 100% |
| Controllers | 100% | 100% | 100% | 100% | | Controllers | 100% | 100% | 100% | 100% |
| Infrastructure (repos) | 80% | 75% | 80% | 80% | | Infrastructure (repos) | 80% | 75% | 80% | 80% |
**Inspect coverage locally:** **Inspect coverage locally:**
@@ -339,21 +350,24 @@ The `defineFactory` function lives in `packages/core-testing/src/factory/define-
--- ---
## 9. Output Validation Tests (R25) ## 9. Output Validation Tests
Every **non-void** use case must have an R25 test that injects a malformed mock response and asserts the use case throws `ZodError`. This verifies that the `xOutputSchema.parse(result)` at the end of each use case actually guards against misbehaving repositories. Every **non-void** use case must have an output-validation test that injects a malformed mock response and asserts the use case throws `ZodError`. This verifies that the `xOutputSchema.parse(result)` at the end of each use case actually guards against misbehaving repositories.
**Void use cases are exempt:** `signOutUseCase`, `deleteMediaUseCase`, and any future use case returning `Promise<void>` skip R25 — they have no output schema. **Void use cases are exempt:** `signOutUseCase`, `deleteMediaUseCase`, and any future use case returning `Promise<void>` — they have no output schema.
**Pattern A — reach into `_articles` (or equivalent backing array) when the typed API prevents you from inserting bad data:** **Pattern A — reach into `_articles` (or equivalent backing array) when the typed API prevents you from inserting bad data:**
```typescript ```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts // packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { ZodError } from "zod"; import { ZodError } from "zod";
import { getArticlesUseCase, getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case"; import {
getArticlesUseCase,
getArticlesOutputSchema,
} from "@/application/use-cases/get-articles.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
describe("getArticlesUseCase output validation (R25)", () => { describe("getArticlesUseCase output validation", () => {
it("throws when the repository returns a malformed article", async () => { it("throws when the repository returns a malformed article", async () => {
const repo = new MockArticlesRepository(); const repo = new MockArticlesRepository();
// bypass typed createArticle by reaching into the backing array directly // bypass typed createArticle by reaching into the backing array directly
@@ -376,7 +390,7 @@ describe("getArticlesUseCase output validation (R25)", () => {
// packages/auth/src/application/use-cases/sign-in.use-case.test.ts // packages/auth/src/application/use-cases/sign-in.use-case.test.ts
import type { IAuthenticationService } from "@/application/services/authentication.service.interface"; import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
describe("signInUseCase output validation (R25)", () => { describe("signInUseCase output validation", () => {
it("throws when authenticationService returns a malformed session", async () => { it("throws when authenticationService returns a malformed session", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
await users.createUser(userFactory.build({ username: "alice" })); await users.createUser(userFactory.build({ username: "alice" }));
@@ -387,16 +401,18 @@ describe("signInUseCase output validation (R25)", () => {
} as unknown as IAuthenticationService; } as unknown as IAuthenticationService;
const useCase = signInUseCase(users, auth); const useCase = signInUseCase(users, auth);
await expect(useCase({ username: "alice", password: "x" })).rejects.toBeInstanceOf(ZodError); await expect(
useCase({ username: "alice", password: "x" }),
).rejects.toBeInstanceOf(ZodError);
}); });
}); });
``` ```
Group R25 tests in a separate `describe` block labelled `"<useCase> output validation (R25)"` so they are easy to grep. Group output-validation tests in a separate `describe` block labelled `"<useCase> output validation"` so they are easy to grep.
--- ---
## 10. Router Error-Mapping Tests (R26) ## 10. Router Error-Mapping Tests
Each feature's `router.test.ts` must assert that thrown domain errors become `TRPCError` with the correct code. The router test uses `xRouter.createCaller({})` and calls real procedures backed by the default mock bindings. Each feature's `router.test.ts` must assert that thrown domain errors become `TRPCError` with the correct code. The router test uses `xRouter.createCaller({})` and calls real procedures backed by the default mock bindings.
@@ -410,7 +426,7 @@ import { blogContainer } from "@/di/container";
import { BlogModule } from "@/di/module"; import { BlogModule } from "@/di/module";
import { blogRouter } from "@/integrations/api/router"; import { blogRouter } from "@/integrations/api/router";
describe("blogRouter (R26 error mapping)", () => { describe("blogRouter error mapping", () => {
beforeEach(() => { beforeEach(() => {
blogContainer.unbindAll(); blogContainer.unbindAll();
blogContainer.load(BlogModule); blogContainer.load(BlogModule);
@@ -450,10 +466,14 @@ For features where a domain error can only be triggered by an empty store (e.g.
it("translates HeaderNotFoundError → NOT_FOUND", async () => { it("translates HeaderNotFoundError → NOT_FOUND", async () => {
@injectable() @injectable()
class NullHeaderRepository implements IHeaderRepository { class NullHeaderRepository implements IHeaderRepository {
async getHeader() { return undefined; } async getHeader() {
return undefined;
}
} }
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
navigationContainer.bind(NAVIGATION_SYMBOLS.IHeaderRepository).to(NullHeaderRepository); navigationContainer
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
.to(NullHeaderRepository);
const caller = navigationRouter.createCaller({}); const caller = navigationRouter.createCaller({});
try { try {
@@ -470,7 +490,7 @@ Every feature needs at least one `NOT_FOUND` (or domain-error) test and one `BAD
--- ---
## 11. Presenter Shape Tests (R27/R28) ## 11. Presenter Shape Tests
When a controller's presenter **reshapes** the use-case output (rather than returning it unchanged), the controller test must assert the **view shape** — not the full use-case output. When a controller's presenter **reshapes** the use-case output (rather than returning it unchanged), the controller test must assert the **view shape** — not the full use-case output.
@@ -485,13 +505,19 @@ describe("signInController", () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
await users.createUser( await users.createUser(
userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" }), userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
}),
); );
const useCase = signInUseCase(users, auth); const useCase = signInUseCase(users, auth);
const controller = signInController(useCase); const controller = signInController(useCase);
const result = await controller({ username: "alice", password: "testpassword" }); const result = await controller({
username: "alice",
password: "testpassword",
});
// assert the VIEW shape (cookie), not the use-case output ({ session, cookie }) // assert the VIEW shape (cookie), not the use-case output ({ session, cookie })
expect(result.name).toBe("session"); expect(result.name).toBe("session");
expect(result.value).toBeTruthy(); expect(result.value).toBeTruthy();
@@ -501,7 +527,7 @@ describe("signInController", () => {
**Identity presenters skip this.** Blog, marketing-pages, navigation, and media controllers all use identity presenters (`return value`). Their controller tests assert on the same fields the use case would return — that is fine, because the presenter does not transform. **Identity presenters skip this.** Blog, marketing-pages, navigation, and media controllers all use identity presenters (`return value`). Their controller tests assert on the same fields the use case would return — that is fine, because the presenter does not transform.
**Rule of thumb:** if `presenter(value)` does anything other than `return value`, write a test that cannot pass by accident — assert a field that only exists on the *view*, not on `XOutput`. **Rule of thumb:** if `presenter(value)` does anything other than `return value`, write a test that cannot pass by accident — assert a field that only exists on the _view_, not on `XOutput`.
Void controllers (`signOutController`, `deleteMediaController`) return `Promise<void>` and have no presenter — no view-shape test applies. Void controllers (`signOutController`, `deleteMediaController`) return `Promise<void>` and have no presenter — no view-shape test applies.
@@ -580,6 +606,7 @@ describe("CommentsRepository", () => {
The `buildSubject` pattern ensures each `run()` call supplies a fresh instance — every contract `it()` starts with a clean repository. The `buildSubject` pattern ensures each `run()` call supplies a fresh instance — every contract `it()` starts with a clean repository.
**File naming convention (post-Plan-8):** **File naming convention (post-Plan-8):**
- Mock implementation: `<x>.repository.mock.ts` (not `mock-<x>.repository.ts`) - Mock implementation: `<x>.repository.mock.ts` (not `mock-<x>.repository.ts`)
- Mock test: `<x>.repository.mock.test.ts` - Mock test: `<x>.repository.mock.test.ts`
- Real implementation: `<x>.repository.ts` (no `payload-` prefix) - Real implementation: `<x>.repository.ts` (no `payload-` prefix)
@@ -655,12 +682,15 @@ E2E tests live in `apps/web-next/e2e/`. The `webServer` block in `apps/web-next/
--- ---
## Asserting spans and captures (Plan 10) ## Asserting spans and captures
Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Repositories also call `logger.captureException` inline; use cases and controllers get capture composed in via `withCapture` at DI bind time. Tests that need to assert either inject `RecordingTracer` + `RecordingLogger`: Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Repositories also call `logger.captureException` inline; use cases and controllers get capture composed in via `withCapture` at DI bind time. Tests that need to assert either inject `RecordingTracer` + `RecordingLogger`:
```ts ```ts
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { withSpan, withCapture } from "@repo/core-shared/instrumentation"; import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";

View File

@@ -2,23 +2,23 @@
A layered approach: direct factory injection + colocated unit tests + Playwright e2e. A layered approach: direct factory injection + colocated unit tests + Playwright e2e.
For the *how* of TDD (red-green-refactor cycle, when to mock, what NOT to test), see [tdd-workflow.md](./tdd-workflow.md). This document covers test *placement* and infrastructure. For the _how_ of TDD (red-green-refactor cycle, when to mock, what NOT to test), see [tdd-workflow.md](./tdd-workflow.md). This document covers test _placement_ and infrastructure.
For the full R25 / R26 / R27 / R28 patterns with worked examples, see [tdd-workflow.md §4 (mock decision tree)](./tdd-workflow.md). For the full output-validation, error-mapping, and view-shape patterns with worked examples, see [tdd-workflow.md §4 (mock decision tree)](./tdd-workflow.md).
Related ADRs: [ADR-012](../decisions/adr-012-lazar-conformance.md) — Clean Architecture conformance; [ADR-013](../decisions/adr-013-input-output-unification.md) — input/output unification + presenter + error middleware. Related ADRs: [ADR-012](../decisions/adr-012-feature-conventions.md) — Clean Architecture conformance; [ADR-013](../decisions/adr-013-input-output-unification.md) — input/output unification + presenter + error middleware.
## Test placement ## Test placement
| Level | Location | Tool | Example | | Level | Location | Tool | Example |
|---|---|---|---| | ------------------------ | --------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------- |
| **Unit (colocated)** | `packages/<feature>/src/entities/models/<entity>.test.ts` | Vitest | Schema validation, type guards | | **Unit (colocated)** | `packages/<feature>/src/entities/models/<entity>.test.ts` | Vitest | Schema validation, type guards |
| **Use case** | `packages/<feature>/src/application/use-cases/<name>.use-case.test.ts` | Vitest | Direct factory injection + R25 output-validation | | **Use case** | `packages/<feature>/src/application/use-cases/<name>.use-case.test.ts` | Vitest | Direct factory injection + output-validation |
| **Controller** | `packages/<feature>/src/interface-adapters/controllers/<name>.controller.test.ts` | Vitest | Direct factory injection + R10 input validation + R27/R28 view shape | | **Controller** | `packages/<feature>/src/interface-adapters/controllers/<name>.controller.test.ts` | Vitest | Direct factory injection + input validation + view shape |
| **Repository contract** | `packages/<feature>/src/infrastructure/repositories/<impl>.repository.test.ts` | Vitest + contract suite | Interface conformance | | **Repository contract** | `packages/<feature>/src/infrastructure/repositories/<impl>.repository.test.ts` | Vitest + contract suite | Interface conformance |
| **Router (integration)** | `packages/<feature>/src/integrations/api/router.test.ts` | Vitest + container | R26 domain → TRPCError mapping | | **Router (integration)** | `packages/<feature>/src/integrations/api/router.test.ts` | Vitest + container | Domain → TRPCError mapping |
| **Feature level** | `packages/<feature>/tests/<name>.feature.test.ts` | Vitest | Cross-layer integration via direct injection | | **Feature level** | `packages/<feature>/tests/<name>.feature.test.ts` | Vitest | Cross-layer integration via direct injection |
| **E2E (app)** | `apps/web-next/e2e/<name>.spec.ts` | Playwright | Full user flow across frontend + backend | | **E2E (app)** | `apps/web-next/e2e/<name>.spec.ts` | Playwright | Full user flow across frontend + backend |
**Colocated vs feature-level:** Colocated tests (`*.test.ts` next to source) test isolated units. Feature-level tests (`tests/` folder) wire the full chain via direct injection and test interactions between layers. **Colocated vs feature-level:** Colocated tests (`*.test.ts` next to source) test isolated units. Feature-level tests (`tests/` folder) wire the full chain via direct injection and test interactions between layers.
@@ -39,10 +39,14 @@ describe("getArticlesUseCase", () => {
it("filters by status", async () => { it("filters by status", async () => {
const repo = new MockArticlesRepository(); const repo = new MockArticlesRepository();
articleFactory.reset(); articleFactory.reset();
await repo.createArticle(articleFactory.build({ id: "1", status: "draft" })); await repo.createArticle(
await repo.createArticle(articleFactory.build({ id: "2", status: "published" })); articleFactory.build({ id: "1", status: "draft" }),
);
await repo.createArticle(
articleFactory.build({ id: "2", status: "published" }),
);
const useCase = getArticlesUseCase(repo); // direct injection const useCase = getArticlesUseCase(repo); // direct injection
const result = await useCase({ status: "published" }); const result = await useCase({ status: "published" });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
}); });
@@ -63,10 +67,13 @@ describe("signInController", () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth); const useCase = signInUseCase(users, auth);
const controller = signInController(useCase); // direct injection const controller = signInController(useCase); // direct injection
const result = await controller({ username: "alice", password: "testpassword" }); const result = await controller({
expect(result.name).toBe("session"); // presenter view shape (R27) username: "alice",
password: "testpassword",
});
expect(result.name).toBe("session"); // presenter view shape
expect(result.value).toBeTruthy(); expect(result.value).toBeTruthy();
}); });
}); });
@@ -86,10 +93,10 @@ import { blogContainer } from "@/di/container";
import { BlogModule } from "@/di/module"; import { BlogModule } from "@/di/module";
import { blogRouter } from "@/integrations/api/router"; import { blogRouter } from "@/integrations/api/router";
describe("blogRouter (R26 error mapping)", () => { describe("blogRouter error mapping", () => {
beforeEach(() => { beforeEach(() => {
blogContainer.unbindAll(); blogContainer.unbindAll();
blogContainer.load(BlogModule); // loads default mock bindings blogContainer.load(BlogModule); // loads default mock bindings
}); });
afterEach(() => { afterEach(() => {
@@ -98,10 +105,9 @@ describe("blogRouter (R26 error mapping)", () => {
it("translates ArticleNotFoundError → NOT_FOUND", async () => { it("translates ArticleNotFoundError → NOT_FOUND", async () => {
const caller = blogRouter.createCaller({}); const caller = blogRouter.createCaller({});
await expect(caller.articleBySlug({ slug: "missing" })) await expect(caller.articleBySlug({ slug: "missing" })).rejects.toSatisfy(
.rejects.toSatisfy((e: unknown) => (e: unknown) => e instanceof TRPCError && e.code === "NOT_FOUND",
e instanceof TRPCError && e.code === "NOT_FOUND" );
);
}); });
}); });
``` ```
@@ -111,7 +117,9 @@ When a test needs a specific repo behaviour at the router level, bind it directl
```typescript ```typescript
beforeEach(() => { beforeEach(() => {
blogContainer.unbindAll(); blogContainer.unbindAll();
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(new AlwaysEmptyRepo()); blogContainer
.bind(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(new AlwaysEmptyRepo());
}); });
``` ```
@@ -137,12 +145,13 @@ The mock repository satisfies the repository interface without touching Payload
### Option 2: Router-level — use the feature's DI container ### Option 2: Router-level — use the feature's DI container
When testing the tRPC router (R26 error-mapping, procedure wiring), bind a mock or stub repository through the feature container in `beforeEach`: When testing the tRPC router (error-mapping, procedure wiring), bind a mock or stub repository through the feature container in `beforeEach`:
```typescript ```typescript
beforeEach(() => { beforeEach(() => {
blogContainer.unbindAll(); blogContainer.unbindAll();
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository) blogContainer
.bind(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(new MockArticlesRepository()); .toConstantValue(new MockArticlesRepository());
}); });
``` ```
@@ -156,25 +165,25 @@ vi.mock("payload", () => ({ getPayload: vi.fn() }));
Then provide a stub via `stubPayloadConfig` from `@repo/core-testing/payload` (see [tdd-workflow.md §4](./tdd-workflow.md) for the full contract-suite pattern). Then provide a stub via `stubPayloadConfig` from `@repo/core-testing/payload` (see [tdd-workflow.md §4](./tdd-workflow.md) for the full contract-suite pattern).
## Test obligations per layer (Plan 9) ## Test obligations per layer
| Layer | Test type | Required by spec | Example | | Layer | Test type | Required by spec | Example |
|---|---|---|---| | ------------------------ | ----------------------- | ---------------- | ------------------------------------------------------------------------ |
| Entity | Schema validation | — | `articleSchema.safeParse(...)` → accepts valid / rejects invalid | | Entity | Schema validation | — | `articleSchema.safeParse(...)` → accepts valid / rejects invalid |
| Use case (input) | Behavior | — | factory injection; assert result shape and filtering | | Use case (input) | Behavior | — | factory injection; assert result shape and filtering |
| Use case (output, R25) | Runtime guarantee | R25 | inject malformed mock output → `.rejects.toBeInstanceOf(ZodError)` | | Use case (output) | Runtime guarantee | — | inject malformed mock output → `.rejects.toBeInstanceOf(ZodError)` |
| Controller (input, R10) | Validation | R10 | `controller({} as unknown)``.rejects.toBeInstanceOf(InputParseError)` | | Controller (input) | Validation | — | `controller({} as unknown)``.rejects.toBeInstanceOf(InputParseError)` |
| Controller (presenter, R27/R28) | View shape | R27/R28 (when reshaping) | `expect(result.name).toBe("session")` (not `result.session`) | | Controller (presenter) | View shape | when reshaping | `expect(result.name).toBe("session")` (not `result.session`) |
| Repository contract | Interface conformance | — | run `defineContractSuite` against mock + real impl | | Repository contract | Interface conformance | — | run `defineContractSuite` against mock + real impl |
| Router (R26) | Domain → TRPCError | R26 | `xRouter.createCaller({}).x(...)` → assert `TRPCError.code` | | Router | Domain → TRPCError | — | `xRouter.createCaller({}).x(...)` → assert `TRPCError.code` |
| Feature-level (`tests/`) | Cross-layer integration | — | wire the chain via direct injection (no container) | | Feature-level (`tests/`) | Cross-layer integration | — | wire the chain via direct injection (no container) |
| E2E | Full user flow | — | Playwright | | E2E | Full user flow | — | Playwright |
**R25** — Every non-void use case ends with `xOutputSchema.parse(result)`. The R25 test proves this: inject a mock that returns a structurally invalid object and assert `ZodError` propagates. **Output validation** — Every non-void use case ends with `xOutputSchema.parse(result)`. The test proves this: inject a mock that returns a structurally invalid object and assert `ZodError` propagates.
**R26** — Every feature has `procedures.ts` with a `defineErrorMiddleware` error map. The R26 test calls the tRPC procedure through `router.createCaller({})` and asserts the correct `TRPCError.code` (e.g. `NOT_FOUND`, `BAD_REQUEST`, `UNAUTHORIZED`). **Error mapping** — Every feature has `procedures.ts` with a `defineErrorMiddleware` error map. The router test calls the tRPC procedure through `router.createCaller({})` and asserts the correct `TRPCError.code` (e.g. `NOT_FOUND`, `BAD_REQUEST`, `UNAUTHORIZED`).
**R27/R28** — Controllers that reshape the use-case output (e.g. auth controllers that return a cookie instead of the full session object) must have a test asserting the view shape — not the raw use-case output. **View shape** — Controllers that reshape the use-case output (e.g. auth controllers that return a cookie instead of the full session object) must have a test asserting the view shape — not the raw use-case output.
## Vitest setup per package ## Vitest setup per package
@@ -308,18 +317,17 @@ Root `turbo.json`:
3. **E2E tests** prove the app works end-to-end (minimal smoke specs initially) 3. **E2E tests** prove the app works end-to-end (minimal smoke specs initially)
4. **Per-feature containers** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container) 4. **Per-feature containers** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container)
## R49 / R50 — Instrumentation testing (Plan 10) ## Instrumentation testing
**R49 — No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert specific Sentry SDK calls add their own `vi.mock(...)` per file. **No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert specific Sentry SDK calls add their own `vi.mock(...)` per file.
**R50 — Repository contracts assert span shape.** Every `__contracts__/<x>-repository.contract.ts` includes a `span emission (R50)` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site: **Repository contracts assert span shape.** Every `__contracts__/<x>-repository.contract.ts` includes a `span emission` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site:
```ts ```ts
const tracer = new RecordingTracer(); const tracer = new RecordingTracer();
articlesRepositoryContract.run( articlesRepositoryContract.run(() => new MockArticlesRepository(tracer), {
() => new MockArticlesRepository(tracer), tracer: () => tracer,
{ tracer: () => tracer }, });
);
``` ```
**Capture vs span assertions:** **Capture vs span assertions:**
@@ -330,5 +338,4 @@ articlesRepositoryContract.run(
- `RecordingLogger.users` — every `setUser` call (history). - `RecordingLogger.users` — every `setUser` call (history).
- `RecordingAuditLog.entries` — every `record(entry)` call. Use to assert audit emissions in feature-package tests **without** importing `@repo/core-audit` (the recording double lives in `@repo/core-testing/instrumentation` and mirrors the `AuditEntry` shape inline to avoid the tooling→core boundary). Also exposes `RecordingAuditLog.erasures` for `eraseSubject` history. See ADR-018 and `docs/guides/audit-and-compliance.md` for what to assert. - `RecordingAuditLog.entries` — every `record(entry)` call. Use to assert audit emissions in feature-package tests **without** importing `@repo/core-audit` (the recording double lives in `@repo/core-testing/instrumentation` and mirrors the `AuditEntry` shape inline to avoid the tooling→core boundary). Also exposes `RecordingAuditLog.erasures` for `eraseSubject` history. See ADR-018 and `docs/guides/audit-and-compliance.md` for what to assert.
**Test cleanup:** call `tracer.reset()`, `logger.reset()`, and `auditLog.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. **Test cleanup:** call `tracer.reset()`, `logger.reset()`, and `auditLog.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. 5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests
5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests