diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html index 80be39b..44757c9 100644 --- a/docs/architecture/data-flow-explainer.html +++ b/docs/architecture/data-flow-explainer.html @@ -895,6 +895,234 @@ details.cf-detail > .detail-body { padding-top: 16px; } +/* ─── Three-roles diagram (anatomy of mock/contract/factory) ─────────── */ + +.three-roles-card { margin-bottom: 40px; } +.three-roles-card h3 { + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 60, "SOFT" 0, "wght" 380; + font-size: 42px; + line-height: 1.0; + letter-spacing: -0.018em; + margin: 0 0 18px; +} +.three-roles-card h3 em { + font-style: italic; + font-variation-settings: "opsz" 60, "SOFT" 100, "wght" 380; + color: var(--accent); +} +.three-roles-card p { + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 16, "SOFT" 40; + font-size: 15.5px; + line-height: 1.6; + color: var(--ink-2); + margin: 0 0 12px; +} +.three-roles-card p strong { color: var(--ink); font-weight: 500; } +.three-roles-card code { + font-family: "JetBrains Mono", monospace; + font-size: 12.5px; + background: var(--paper-2); + padding: 1px 5px; + border-radius: 2px; +} + +.three-roles-diagram { + display: flex; + flex-direction: column; + gap: 0; + margin: 32px auto; + max-width: 760px; +} + +.role { + border: 1px solid var(--ink); + background: var(--paper); + padding: 16px 20px; + border-radius: 3px; + position: relative; +} +.role .role-where { + font-family: "JetBrains Mono", monospace; + font-size: 9.5px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--ink-3); + margin-bottom: 6px; +} +.role > strong { + display: block; + font-family: "JetBrains Mono", monospace; + font-weight: 500; + color: var(--ink); + font-size: 14.5px; + margin-bottom: 6px; + letter-spacing: 0.01em; +} +.role .role-what { + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 14, "SOFT" 40; + font-style: italic; + font-size: 13.5px; + line-height: 1.4; + color: var(--ink-2); + margin: 0; +} +.role .role-what em { + font-style: italic; + font-variation-settings: "opsz" 14, "SOFT" 100, "wght" 460; + color: var(--accent); + font-weight: 460; +} +.role .role-what code { + font-family: "JetBrains Mono", monospace; + font-style: normal; + font-size: 12px; + background: var(--paper-2); + padding: 1px 4px; + border-radius: 2px; +} + +.role-interface { + border-width: 2px; + background: var(--paper-3); +} +.role-real { + background: #fbf1de; + border-color: var(--accent); +} +.role-real .role-where { color: var(--accent); } +.role-contract { + border-style: dashed; + border-color: var(--accent-2); +} +.role-contract .role-where { color: var(--accent-2); } +.role-factory { + border-color: var(--ok); + align-self: center; + width: min(78%, 540px); +} +.role-factory .role-where { color: var(--ok); } + +.roles-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.role-arrow { + align-self: center; + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--ink-3); + padding: 14px 0; + position: relative; +} +.role-arrow::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + width: 1px; + height: 14px; + background: var(--rule-strong); + transform: translateX(-0.5px); +} +.role-arrow::after { + content: ""; + position: absolute; + left: 50%; + bottom: 0; + width: 1px; + height: 14px; + background: var(--rule-strong); + transform: translateX(-0.5px); +} + +.role-jobs { + list-style: none; + padding: 0; + margin: 14px 0 0; + counter-reset: job; +} +ol.role-jobs { counter-reset: job; } +ol.role-jobs > li { + counter-increment: job; + position: relative; + padding: 14px 18px 14px 56px; + margin-bottom: 8px; + background: var(--paper-3); + border-left: 2px solid var(--accent); + border-radius: 0 3px 3px 0; + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 16, "SOFT" 40; + font-size: 15.5px; + line-height: 1.55; + color: var(--ink); +} +ol.role-jobs > li::before { + content: counter(job, decimal-leading-zero); + position: absolute; + left: 18px; + top: 14px; + font-family: "JetBrains Mono", monospace; + font-size: 11px; + letter-spacing: 0.18em; + color: var(--accent); +} +ol.role-jobs > li strong { + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 16, "SOFT" 30, "wght" 520; + font-weight: 500; + color: var(--ink); +} +ol.role-jobs > li code { + font-family: "JetBrains Mono", monospace; + font-size: 12.5px; + background: var(--paper-2); + padding: 1px 5px; + border-radius: 2px; +} + +ul.role-jobs { + list-style: none; + padding: 0; + margin: 14px 0 0; +} +ul.role-jobs > li { + padding: 12px 18px; + margin-bottom: 8px; + background: var(--paper-3); + border-left: 2px solid var(--accent-2); + border-radius: 0 3px 3px 0; + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 16, "SOFT" 40; + font-size: 15.5px; + line-height: 1.55; + color: var(--ink); +} +ul.role-jobs > li strong { + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 16, "SOFT" 30, "wght" 520; + font-weight: 500; + color: var(--ink); +} +ul.role-jobs > li code { + font-family: "JetBrains Mono", monospace; + font-size: 12.5px; + background: var(--paper-2); + padding: 1px 5px; + border-radius: 2px; +} + +@media (max-width: 700px) { + .roles-pair { grid-template-columns: 1fr; } + .role-factory { width: 100%; } +} + /* ─── Verdict ────────────────────────────────────────────────────────── */ .verdict-block { @@ -1073,7 +1301,7 @@ footer .colophon {
Two small testing utilities you'll see in every feature: __contracts__/<x>-repository.contract.ts and __factories__/<x>.factory.ts. They look optional — they aren't, once you have more than two implementations of the same interface.
Three things in every feature live near tests but play different roles. The mock repository is a real implementation of the repository interface — it's also the default DI binding. The contract is a portable test suite that runs against any implementation. The factory is a builder for valid entity values. The mock isn't only a test thing; that's the part that surprises people.
The mock is the surprising one. People assume it lives in __mocks__/ because tests use it — but the DI container needs it as the default binding at runtime, and reaching into __mocks__/ from production code crosses a boundary. So the mock lives next to the real implementation, in infrastructure/repositories/. They're siblings.
it() blocks, run twice (once per impl)Article entities with overridable defaultsThe mock has two jobs.
+BlogModule binds IArticlesRepository to MockArticlesRepository at module-load time. Anything resolving that symbol — use cases, controllers, the whole chain — gets the mock until bindProductionBlog(config) swaps it for the real Payload-backed one. See §03.new MockArticlesRepository() and pass it directly into the use-case factory function. No DI involved — just a closure with a fake repo.The contract and factory are pure test ergonomics — they only show up in *.test.ts files. Both live under __-prefixed directories that signal "test territory; not part of the public surface; not imported by runtime code." Their roles:
it() blocks parameterized by buildSubject. Run it against the mock, run it against the real impl. If they diverge — your mock is lying to you and you'd never have caught it without the contract.articleFactory.build({ slug: "x" }) hands you a valid Article with sensible defaults; you only override the fields the test actually cares about. Used by the contract and by every use-case / controller test.This is from packages/blog/src/di/module.ts — the very first binding in the module is the mock. Everything downstream (use cases, controllers) resolves through this default. bindProductionBlog(config) later replaces only this one line at app boot — use case + controller bindings stay put.
export const BlogModule = new ContainerModule((bind) => { + // 1) Mock is the DEFAULT binding for the repo symbol. + // Dev server, unit tests, storybook all resolve to this. + bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository) + .to(MockArticlesRepository); + + // 2) Use cases consume IArticlesRepository — they don't know or + // care which impl they got. Same factory function in either mode. + bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase) + .toDynamicValue((ctx) => + getArticlesUseCase( + ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository), + ), + ); + // ... + 5 more bindings, all the same shape. +});+
Use-case + controller tests skip DI entirely. They construct the mock and pass it as the first argument to the use-case factory, then call the resulting closure with the input. Three lines of setup, then assertions.
+it("filters by status", async () => { + // Construct the mock directly — no DI container, no rebinding. + const repo = new MockArticlesRepository(); + + // Use the factory to seed valid entities (only override what we care about). + articleFactory.reset(); + await repo.createArticle(articleFactory.build({ status: "draft" })); + await repo.createArticle(articleFactory.build({ status: "published" })); + + // Inject the mock into the use-case factory; call the resulting closure. + const useCase = getArticlesUseCase(repo); + const result = await useCase({ status: "published" }); + + expect(result).toHaveLength(1); +});+
Two tiny test files, one shared suite. If the suite ever fails on the real impl but passes on the mock — your mock is lying about Payload's behavior and you'd ship a bug. The factory is doing real work here too: every it() in the suite uses articleFactory.build(...) for seed data, so the assertions stay readable.
describe("MockArticlesRepository", () => { + articlesRepositoryContract.run(async () => new MockArticlesRepository()); +}); + +// articles.repository.test.ts (Payload-backed) — same suite, real impl +vi.mock("payload", () => ({ getPayload: vi.fn() })); + +describe("ArticlesRepository (Payload)", () => { + articlesRepositoryContract.run(async () => { + const stub = buildPayloadStub(); + (getPayload as Mock).mockResolvedValue(stub); + return new ArticlesRepository(stubPayloadConfig); + }); +});+
When you run pnpm test --filter @repo/blog, the contract's twelve it() blocks run twice — once per implementation. Twenty-four assertions for the price of writing twelve.