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

@@ -24,7 +24,9 @@ describe("getArticleBySlugController", () => {
it("returns the article when the slug exists", async () => {
const repo = new MockArticlesRepository();
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 controller = getArticleBySlugController(useCase);
@@ -38,9 +40,9 @@ describe("getArticleBySlugController", () => {
const useCase = getArticleBySlugUseCase(repo);
const controller = getArticleBySlugController(useCase);
await expect(
controller({ slug: "no-such-slug" }),
).rejects.toBeInstanceOf(ArticleNotFoundError);
await expect(controller({ slug: "no-such-slug" })).rejects.toBeInstanceOf(
ArticleNotFoundError,
);
});
});
```
@@ -60,23 +62,31 @@ pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
```typescript
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts
import type { IGetArticleBySlugUseCase, GetArticleBySlugOutput } from
"../application/use-cases/get-article-by-slug.use-case";
import type {
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 { InputParseError } from "../entities/errors/common";
function presenter(value: GetArticleBySlugOutput) { return value; }
function presenter(value: GetArticleBySlugOutput) {
return value;
}
export function getArticleBySlugController(useCase: IGetArticleBySlugUseCase) {
return async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticleBySlugInputSchema.safeParse(input);
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));
};
}
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
export type IGetArticleBySlugController = ReturnType<
typeof getArticleBySlugController
>;
```
**Run again — confirm GREEN:**
@@ -140,6 +150,7 @@ describe("getArticleBySlugController", () => {
```
Rules:
- `describe` names the class or function under test — not the file.
- `it` uses active voice: `returns`, `throws`, `filters`, `creates`.
- 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
| Layer | Tool | Target ratio | Location pattern |
|---|---|---|---|
| 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` |
| 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` |
| 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` |
| E2E | Playwright | Few — smoke + critical flows | `apps/web-next/e2e/*.spec.ts` |
| Layer | Tool | Target ratio | Location pattern |
| ---------------------------- | ----------------------- | ---------------------------- | ---------------------------------------------- |
| 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` |
| 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` |
| 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` |
| 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.
@@ -260,13 +271,13 @@ Entities and use cases have the highest ratio because they encode business rules
## 7. Coverage Targets
| Scope | Statements | Branches | Functions | Lines |
|---|---|---|---|---|
| Baseline (all packages) | 80% | 75% | 80% | 80% |
| Entities | 100% | 100% | 100% | 100% |
| Use cases | 100% | 100% | 100% | 100% |
| Controllers | 100% | 100% | 100% | 100% |
| Infrastructure (repos) | 80% | 75% | 80% | 80% |
| Scope | Statements | Branches | Functions | Lines |
| ----------------------- | ---------- | -------- | --------- | ----- |
| Baseline (all packages) | 80% | 75% | 80% | 80% |
| Entities | 100% | 100% | 100% | 100% |
| Use cases | 100% | 100% | 100% | 100% |
| Controllers | 100% | 100% | 100% | 100% |
| Infrastructure (repos) | 80% | 75% | 80% | 80% |
**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:**
```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
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";
describe("getArticlesUseCase output validation (R25)", () => {
describe("getArticlesUseCase output validation", () => {
it("throws when the repository returns a malformed article", async () => {
const repo = new MockArticlesRepository();
// 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
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 () => {
const users = new MockUsersRepository([]);
await users.createUser(userFactory.build({ username: "alice" }));
@@ -387,16 +401,18 @@ describe("signInUseCase output validation (R25)", () => {
} as unknown as IAuthenticationService;
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.
@@ -410,7 +426,7 @@ import { blogContainer } from "@/di/container";
import { BlogModule } from "@/di/module";
import { blogRouter } from "@/integrations/api/router";
describe("blogRouter (R26 error mapping)", () => {
describe("blogRouter error mapping", () => {
beforeEach(() => {
blogContainer.unbindAll();
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 () => {
@injectable()
class NullHeaderRepository implements IHeaderRepository {
async getHeader() { return undefined; }
async getHeader() {
return undefined;
}
}
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
navigationContainer.bind(NAVIGATION_SYMBOLS.IHeaderRepository).to(NullHeaderRepository);
navigationContainer
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
.to(NullHeaderRepository);
const caller = navigationRouter.createCaller({});
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.
@@ -485,13 +505,19 @@ describe("signInController", () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
await users.createUser(
userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" }),
userFactory.build({
username: "alice",
passwordHash: "hashed_testpassword",
}),
);
const useCase = signInUseCase(users, auth);
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 })
expect(result.name).toBe("session");
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.
**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.
@@ -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.
**File naming convention (post-Plan-8):**
- Mock implementation: `<x>.repository.mock.ts` (not `mock-<x>.repository.ts`)
- Mock test: `<x>.repository.mock.test.ts`
- 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`:
```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 { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";