# Vertical Refactor — Plan 2: Blog Feature (Canonical Migration) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Migrate the existing `content/articles` domain (currently inside `packages/core` and `packages/cms-core`) into a new vertical `packages/blog` feature package with the full canonical layered shape: entities → application → infrastructure → interface-adapters → di → integrations/cms → integrations/api → ui. Wire the feature's `/cms` export into `core-cms` composition and its `/api` export into `core-api` composition. Establish the per-feature InversifyJS container pattern. Pattern proven here will be replicated for `auth`, `media`, `marketing-pages`, and `navigation` in subsequent plans. **Architecture:** One new package (`@repo/blog`) with the full canonical structure. Per-feature InversifyJS container holds the `IArticlesRepository` binding (mock by default for tests; payload-backed in production via `getPayload({ config })` from `@repo/core-cms`). tRPC procedures in `integrations/api/router.ts` call controllers, which Zod-parse and delegate to use-cases, which resolve the repo via the feature container. Articles collection moves from `cms-core` into `blog/integrations/cms/collections/`. **Tech Stack:** InversifyJS 6.x with reflect-metadata, Vitest 3.x, Zod 3, tRPC 11, Payload 3.14 Local API. **Plan position:** Plan 2 of 6 in the vertical-refactor sequence (revised from original 4 during execution to keep each plan digestible): - Plan 1 ✅ Foundation — core-* package scaffolds + core-shared primitives + core-cms stub - **Plan 2 (this doc):** Blog feature (canonical) — full layered shape, simplified schema (no cross-feature relationships) - Plan 3: Auth + Media features + restore blog's `author` relationship and `featuredImage` - Plan 4: Marketing-pages + Navigation features - Plan 5: App + UI integration — core-trpc client/providers, route handlers, example pages - Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite **Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` --- ## Schema simplifications in this plan The existing `Articles` collection in `packages/cms-core/src/collections/articles/fields.ts` declares two cross-feature references: - `author: relationship → users` (users collection is owned by the auth feature, migrated in Plan 3) - `featuredImage: upload → media` (media collection is owned by the media feature, migrated in Plan 3) To keep Plan 2 atomic, the migrated `articles.ts` collection here uses **simplified placeholders**: - `author` becomes a `text` field (string). - `featuredImage` is omitted entirely. **Plan 3 restores both fields to their relationship/upload form.** This is called out explicitly in those Plan 3 tasks. The simplification is also documented in a TODO comment in the new `articles.ts` so an engineer reading the file later understands. The domain `Article.content` field is also widened: the existing entity has `content: z.string()` but Payload's actual storage is rich-text JSON. New entity uses `content: z.unknown()` to honestly model rich-text content (matches spec §11.9's `body: unknown` pattern). The mock repository's tests are adjusted accordingly. --- ## File Structure (this plan creates/modifies) **Create — new `@repo/blog` package:** - `packages/blog/{package.json,tsconfig.json,turbo.json,vitest.config.ts}` - `packages/blog/src/index.ts` — barrel export - `packages/blog/src/entities/article.ts` + `article.test.ts` - `packages/blog/src/entities/errors.ts` - `packages/blog/src/application/repositories/articles-repository.interface.ts` - `packages/blog/src/application/use-cases/get-articles.use-case.ts` + `get-articles.use-case.test.ts` - `packages/blog/src/application/use-cases/create-article.use-case.ts` + `create-article.use-case.test.ts` - `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts` - `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` + `payload-articles.repository.test.ts` - `packages/blog/src/interface-adapters/controllers/articles.controller.ts` + `articles.controller.test.ts` - `packages/blog/src/di/symbols.ts` - `packages/blog/src/di/module.ts` - `packages/blog/src/di/container.ts` + `container.test.ts` - `packages/blog/src/integrations/cms/collections/articles.ts` - `packages/blog/src/integrations/cms/index.ts` - `packages/blog/src/integrations/api/router.ts` - `packages/blog/src/ui/query.ts` - `packages/blog/tests/articles.feature.test.ts` **Modify:** - `packages/core-cms/src/payload.config.ts` — import + register `articles` from `@repo/blog/cms` - `packages/core-api/src/root.ts` — import + register `blogRouter` from `@repo/blog/api` - `tsconfig.base.json` — add `@repo/blog`, `@repo/blog/cms`, `@repo/blog/api` aliases - `apps/cms/package.json` — add `@repo/blog` dependency (so the workspace resolves the schema import) **Do NOT touch:** - `packages/core/` (still owns auth domain — left intact for Plan 3) - `packages/api/` (still wires the old content router; orphaned but unused after Plan 2 — actually used by ZERO consumers since reference apps are empty) - `packages/cms-core/src/collections/articles/` — the OLD location. After Plan 2, both old and new exist; Plan 6 deletes `cms-core` entirely. Apps/cms reads from `core-cms` which only sees the NEW location. --- ## Phase 1: Blog package skeleton + entity layer ### Task 2.1: Scaffold @repo/blog package **Files:** - Create: `packages/blog/package.json` - Create: `packages/blog/tsconfig.json` - Create: `packages/blog/turbo.json` - Create: `packages/blog/vitest.config.ts` - Create: `packages/blog/src/index.ts` - [ ] **Step 1: Create package.json** ```json { "name": "@repo/blog", "private": true, "version": "0.0.0", "type": "module", "exports": { ".": "./src/index.ts", "./cms": "./src/integrations/cms/index.ts", "./api": "./src/integrations/api/router.ts" }, "scripts": { "build": "tsc --noEmit", "lint": "eslint .", "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { "@repo/core-cms": "workspace:*", "@repo/core-shared": "workspace:*", "@trpc/server": "^11.0.0", "inversify": "^6.2.0", "payload": "^3.14.0", "reflect-metadata": "^0.2.2", "zod": "^3.24.0" }, "devDependencies": { "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.0.0", "vitest": "^3.1.0" } } ``` - [ ] **Step 2: Create tsconfig.json** ```json { "extends": "@repo/typescript-config/base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "lib": ["ES2022", "DOM"], "jsx": "preserve", "paths": { "@/*": ["./src/*"] } }, "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } ``` > Note: `@/*` path alias is feature-local (resolves to `packages/blog/src/*`). This is the same pattern `packages/core/tsconfig.json` already uses. - [ ] **Step 3: Create turbo.json** ```json { "extends": ["//"], "tags": ["feature"] } ``` - [ ] **Step 4: Create vitest.config.ts** ```typescript import path from "node:path"; import { baseVitestConfig } from "@repo/typescript-config/vitest.base"; export default { ...baseVitestConfig, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, }; ``` > Note: Vitest does NOT automatically read tsconfig `paths`. The `@/` alias used in `src/**/*.ts` source/test files must be declared explicitly in vitest.config.ts via `resolve.alias`. Same pattern repeats for every feature package's vitest.config.ts. - [ ] **Step 5: Create empty index.ts** ```typescript export {}; ``` - [ ] **Step 6: Add path aliases to root tsconfig.base.json** Read the current `tsconfig.base.json`. Add three new entries to `compilerOptions.paths` (alphabetically — between `@repo/core-ui` and `@repo/core-shared` if existing list is sorted; otherwise just at the end of the paths object before the closing brace): ```json "@repo/blog": ["packages/blog/src/index.ts"], "@repo/blog/cms": ["packages/blog/src/integrations/cms/index.ts"], "@repo/blog/api": ["packages/blog/src/integrations/api/router.ts"] ``` - [ ] **Step 7: Install + verify** Run: `pnpm install` Expected: `@repo/blog` registered in workspace; lockfile updated. - [ ] **Step 8: Commit** ```bash git add packages/blog tsconfig.base.json pnpm-lock.yaml git commit -m "feat(blog): scaffold empty package with feature tag + path aliases" ``` --- ### Task 2.2: Port Article entity (with content widened to unknown) **Files:** - Create: `packages/blog/src/entities/article.ts` - Create: `packages/blog/src/entities/article.test.ts` - Create: `packages/blog/src/entities/errors.ts` - [ ] **Step 1: Write the failing test** ```typescript // packages/blog/src/entities/article.test.ts import { describe, expect, it } from "vitest"; import { articleSchema, articleStatusSchema, type Article } from "./article"; describe("articleSchema", () => { it("accepts a minimal valid article with default status", () => { const result = articleSchema.parse({ id: "abc", title: "Hello", slug: "hello", content: { type: "doc", children: [] }, authorId: "u1", createdAt: new Date(), updatedAt: new Date(), }); expect(result.status).toBe("draft"); }); it("accepts unknown rich-text content", () => { const result = articleSchema.parse({ id: "abc", title: "Hello", slug: "hello", content: "any string is also fine", authorId: "u1", createdAt: new Date(), updatedAt: new Date(), }); expect(result.content).toBe("any string is also fine"); }); it("rejects empty title", () => { expect(() => articleSchema.parse({ id: "a", title: "", slug: "s", content: null, authorId: "u", createdAt: new Date(), updatedAt: new Date(), }), ).toThrow(); }); it("rejects title over 255 chars", () => { expect(() => articleSchema.parse({ id: "a", title: "x".repeat(256), slug: "s", content: null, authorId: "u", createdAt: new Date(), updatedAt: new Date(), }), ).toThrow(); }); }); describe("articleStatusSchema", () => { it("accepts 'draft' and 'published'", () => { expect(articleStatusSchema.parse("draft")).toBe("draft"); expect(articleStatusSchema.parse("published")).toBe("published"); }); it("rejects unknown status", () => { expect(() => articleStatusSchema.parse("archived")).toThrow(); }); }); describe("Article type", () => { it("widens content to unknown", () => { const _example: Article = { id: "x", title: "t", slug: "s", content: { whatever: true }, status: "draft", authorId: "u", createdAt: new Date(), updatedAt: new Date(), }; expect(_example).toBeDefined(); }); }); ``` - [ ] **Step 2: Run — expect failure** Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts` Expected: FAIL — "Cannot find module './article'" - [ ] **Step 3: Implement article entity** ```typescript // packages/blog/src/entities/article.ts import { z } from "zod"; export const articleStatusSchema = z.enum(["draft", "published"]); export const articleSchema = z.object({ id: z.string(), title: z.string().min(1).max(255), slug: z.string().min(1).max(255), content: z.unknown(), status: articleStatusSchema.default("draft"), authorId: z.string(), createdAt: z.date(), updatedAt: z.date(), }); export type Article = z.infer; export type ArticleStatus = z.infer; ``` - [ ] **Step 4: Create errors.ts (domain-specific errors)** ```typescript // packages/blog/src/entities/errors.ts export class ArticleNotFoundError extends Error { constructor(message = "Article not found", options?: ErrorOptions) { super(message, options); } } export class InputParseError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); } } ``` > Note: `InputParseError` is duplicated here because the new architecture has each feature own its errors (no shared `@repo/core/entities/errors`). The class is small. - [ ] **Step 5: Run — expect pass** Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts` Expected: PASS — 7 tests. - [ ] **Step 6: Commit** ```bash git add packages/blog/src/entities git commit -m "feat(blog): add Article entity with rich-text content + domain errors" ``` --- ## Phase 2: Application layer (use-cases + repository interface) ### Task 2.3: Define IArticlesRepository interface **Files:** - Create: `packages/blog/src/application/repositories/articles-repository.interface.ts` - [ ] **Step 1: Implement** (no test — interface has no behavior; exercised through use-case tests) ```typescript // packages/blog/src/application/repositories/articles-repository.interface.ts import type { Article } from "@/entities/article"; export interface IArticlesRepository { getArticle(id: string): Promise
; getArticleBySlug(slug: string): Promise
; getArticles(options?: { status?: string; authorId?: string; limit?: number; offset?: number; }): Promise; createArticle(input: Article): Promise
; updateArticle( id: string, input: Partial
, ): Promise
; } ``` > Note: `getArticleBySlug` is added beyond the original `packages/core` interface — needed by `articleBySlug` tRPC procedure (the canonical example query in spec §11.15). - [ ] **Step 2: Verify it compiles** Run: `cd packages/blog && pnpm typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/blog/src/application/repositories git commit -m "feat(blog): add IArticlesRepository interface" ``` --- ### Task 2.4: Implement getArticles use-case + tests **Files:** - Create: `packages/blog/src/application/use-cases/get-articles.use-case.ts` - Create: `packages/blog/src/application/use-cases/get-articles.use-case.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // packages/blog/src/application/use-cases/get-articles.use-case.test.ts import { beforeEach, describe, expect, it } from "vitest"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; import { getArticlesUseCase } from "./get-articles.use-case"; describe("getArticlesUseCase", () => { let repo: MockArticlesRepository; beforeEach(() => { if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } repo = new MockArticlesRepository(); blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(repo); }); it("returns all articles with no filters", async () => { const now = new Date(); await repo.createArticle({ id: "1", title: "A", slug: "a", content: null, status: "draft", authorId: "u1", createdAt: now, updatedAt: now, }); const result = await getArticlesUseCase(); expect(result).toHaveLength(1); expect(result[0]?.id).toBe("1"); }); it("filters by status", async () => { const now = new Date(); await repo.createArticle({ id: "1", title: "A", slug: "a", content: null, status: "draft", authorId: "u1", createdAt: now, updatedAt: now, }); await repo.createArticle({ id: "2", title: "B", slug: "b", content: null, status: "published", authorId: "u1", createdAt: now, updatedAt: now, }); const result = await getArticlesUseCase({ status: "published" }); expect(result).toHaveLength(1); expect(result[0]?.id).toBe("2"); }); }); ``` - [ ] **Step 2: Run — expect failure** Run: `cd packages/blog && pnpm vitest run src/application/use-cases/get-articles.use-case.test.ts` Expected: FAIL — multiple "Cannot find module" errors (the test imports from `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository`, none of which exist yet). > This is intentional: TDD here means the use-case test exercises the full DI container shape AND the mock repo. Both will be implemented in subsequent tasks. For now, just write the use-case to satisfy its direct dependencies. - [ ] **Step 3: Implement use-case** ```typescript // packages/blog/src/application/use-cases/get-articles.use-case.ts import type { Article } from "@/entities/article"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; export async function getArticlesUseCase(options?: { status?: string; authorId?: string; limit?: number; offset?: number; }): Promise { const repo = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, ); return repo.getArticles(options); } ``` > Note: This still won't compile or run because `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository` don't exist yet. Test will continue to fail. That's expected — Tasks 2.5-2.7 implement those, then this test passes. - [ ] **Step 4: Commit (test + impl, both currently failing — establishes the contract)** ```bash git add packages/blog/src/application/use-cases/get-articles.use-case.ts packages/blog/src/application/use-cases/get-articles.use-case.test.ts git commit -m "feat(blog): add getArticlesUseCase (test red until DI + mock repo exist)" ``` --- ### Task 2.5: Implement createArticle use-case + tests **Files:** - Create: `packages/blog/src/application/use-cases/create-article.use-case.ts` - Create: `packages/blog/src/application/use-cases/create-article.use-case.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // packages/blog/src/application/use-cases/create-article.use-case.test.ts import { beforeEach, describe, expect, it } from "vitest"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; import { createArticleUseCase } from "./create-article.use-case"; describe("createArticleUseCase", () => { let repo: MockArticlesRepository; beforeEach(() => { if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } repo = new MockArticlesRepository(); blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(repo); }); it("creates an article in draft status with auto-generated slug", async () => { const result = await createArticleUseCase({ title: "Hello World", content: "body", authorId: "u1", }); expect(result.title).toBe("Hello World"); expect(result.slug).toBe("hello-world"); expect(result.status).toBe("draft"); expect(result.id).toBeTruthy(); const stored = await repo.getArticle(result.id); expect(stored).toBeDefined(); }); it("uses provided slug when supplied", async () => { const result = await createArticleUseCase({ title: "Whatever", content: "body", authorId: "u1", slug: "custom-slug", }); expect(result.slug).toBe("custom-slug"); }); }); ``` - [ ] **Step 2: Run — expect failure** Run: `cd packages/blog && pnpm vitest run src/application/use-cases/create-article.use-case.test.ts` Expected: FAIL — module-not-found errors. - [ ] **Step 3: Implement use-case** ```typescript // packages/blog/src/application/use-cases/create-article.use-case.ts import type { Article } from "@/entities/article"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } export async function createArticleUseCase(input: { title: string; content: unknown; authorId: string; slug?: string; }): Promise
{ const repo = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, ); const now = new Date(); const article: Article = { id: crypto.randomUUID(), title: input.title, slug: input.slug ?? generateSlug(input.title), content: input.content, status: "draft", authorId: input.authorId, createdAt: now, updatedAt: now, }; return repo.createArticle(article); } ``` - [ ] **Step 4: Commit (test still failing — DI not yet implemented)** ```bash git add packages/blog/src/application/use-cases/create-article.use-case.ts packages/blog/src/application/use-cases/create-article.use-case.test.ts git commit -m "feat(blog): add createArticleUseCase (test red until DI + mock repo exist)" ``` --- ## Phase 3: Infrastructure layer (mock + payload repos) ### Task 2.6: Mock articles repository **Files:** - Create: `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts` - [ ] **Step 1: Implement** (no separate test — exercised by use-case tests) ```typescript // packages/blog/src/infrastructure/repositories/mock-articles.repository.ts import "reflect-metadata"; import { injectable } from "inversify"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import type { Article } from "@/entities/article"; @injectable() export class MockArticlesRepository implements IArticlesRepository { private _articles: Article[] = []; async getArticle(id: string): Promise
{ return this._articles.find((a) => a.id === id); } async getArticleBySlug(slug: string): Promise
{ return this._articles.find((a) => a.slug === slug); } async getArticles(options?: { status?: string; authorId?: string; limit?: number; offset?: number; }): Promise { let result = [...this._articles]; if (options?.status) { result = result.filter((a) => a.status === options.status); } if (options?.authorId) { result = result.filter((a) => a.authorId === options.authorId); } const offset = options?.offset ?? 0; const limit = options?.limit ?? 50; return result.slice(offset, offset + limit); } async createArticle(input: Article): Promise
{ this._articles.push(input); return input; } async updateArticle( id: string, input: Partial
, ): Promise
{ const index = this._articles.findIndex((a) => a.id === id); if (index === -1) return undefined; const current = this._articles[index]; if (!current) return undefined; const updated: Article = { ...current, ...input, id: current.id }; this._articles[index] = updated; return updated; } } ``` - [ ] **Step 2: Verify it compiles** (use-case tests still won't pass — DI container not yet implemented) Run: `cd packages/blog && pnpm typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/blog/src/infrastructure/repositories/mock-articles.repository.ts git commit -m "feat(blog): add MockArticlesRepository for tests" ``` --- ### Task 2.7: Per-feature DI container (symbols, module, container) **Files:** - Create: `packages/blog/src/di/symbols.ts` - Create: `packages/blog/src/di/module.ts` - Create: `packages/blog/src/di/container.ts` - Create: `packages/blog/src/di/container.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // packages/blog/src/di/container.test.ts import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { blogContainer } from "./container"; import { BLOG_SYMBOLS } from "./symbols"; import { BlogModule } from "./module"; import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; describe("blogContainer", () => { beforeEach(() => { blogContainer.unbindAll(); blogContainer.load(BlogModule); }); afterEach(() => { blogContainer.unbindAll(); }); it("resolves IArticlesRepository to MockArticlesRepository by default binding", () => { const repo = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, ); expect(repo).toBeInstanceOf(MockArticlesRepository); }); it("supports rebinding to a custom repo", () => { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); const custom = new MockArticlesRepository(); blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(custom); const resolved = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, ); expect(resolved).toBe(custom); }); }); ``` - [ ] **Step 2: Run — expect failure** Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts` Expected: FAIL — module-not-found errors. - [ ] **Step 3: Implement symbols.ts** ```typescript // packages/blog/src/di/symbols.ts export const BLOG_SYMBOLS = { IArticlesRepository: Symbol.for("blog:IArticlesRepository"), } as const; ``` > Note: Symbol descriptions are namespaced (`blog:IArticlesRepository`) so debug output is clear and there's no collision with other features' symbols at the JavaScript level (each feature has its own container, so collisions can't actually happen — this is just hygiene). - [ ] **Step 4: Implement module.ts** ```typescript // packages/blog/src/di/module.ts import { ContainerModule, type interfaces } from "inversify"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; import { BLOG_SYMBOLS } from "./symbols"; export const BlogModule = new ContainerModule((bind: interfaces.Bind) => { bind(BLOG_SYMBOLS.IArticlesRepository).to( MockArticlesRepository, ); }); ``` > Note: Default binding is `MockArticlesRepository`. The payload-backed repo (Task 2.8) is bound at app boot (Plan 5) by rebinding before serving requests. Tests rebind per `beforeEach`. This keeps the package usable in isolation (e.g., for unit tests, Storybook dev) without requiring a database. - [ ] **Step 5: Implement container.ts** ```typescript // packages/blog/src/di/container.ts import "reflect-metadata"; import { Container } from "inversify"; import { BlogModule } from "./module"; export const blogContainer = new Container({ defaultScope: "Singleton" }); blogContainer.load(BlogModule); ``` - [ ] **Step 6: Run — expect pass** Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts` Expected: PASS — 2 tests. - [ ] **Step 7: Run the previously-red use-case tests** Run: `cd packages/blog && pnpm vitest run src/application/use-cases` Expected: PASS — 4 tests (2 from `getArticles`, 2 from `createArticle`). - [ ] **Step 8: Commit** ```bash git add packages/blog/src/di git commit -m "feat(blog): add per-feature InversifyJS container (symbols, module, container)" ``` --- ### Task 2.8: Payload articles repository **Files:** - Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` - Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts import { describe, expect, it, vi } from "vitest"; import { PayloadArticlesRepository } from "./payload-articles.repository"; // Mock the payload module so we don't need a real DB vi.mock("payload", () => ({ getPayload: vi.fn(), })); vi.mock("@repo/core-cms", () => ({ default: {} as never, })); describe("PayloadArticlesRepository", () => { it("maps a Payload doc to a domain Article on getArticleBySlug", async () => { const { getPayload } = await import("payload"); const findMock = vi.fn().mockResolvedValue({ docs: [ { id: "p-123", title: "Hello", slug: "hello", content: { type: "doc", children: [] }, status: "published", author: "u1", createdAt: "2026-05-04T12:00:00.000Z", updatedAt: "2026-05-04T12:00:00.000Z", }, ], }); (getPayload as ReturnType).mockResolvedValue({ find: findMock, }); const repo = new PayloadArticlesRepository(); const result = await repo.getArticleBySlug("hello"); expect(findMock).toHaveBeenCalledWith({ collection: "articles", where: { slug: { equals: "hello" } }, limit: 1, overrideAccess: false, }); expect(result?.id).toBe("p-123"); expect(result?.slug).toBe("hello"); expect(result?.status).toBe("published"); expect(result?.authorId).toBe("u1"); expect(result?.createdAt).toBeInstanceOf(Date); }); it("returns undefined when slug is not found", async () => { const { getPayload } = await import("payload"); (getPayload as ReturnType).mockResolvedValue({ find: vi.fn().mockResolvedValue({ docs: [] }), }); const repo = new PayloadArticlesRepository(); const result = await repo.getArticleBySlug("missing"); expect(result).toBeUndefined(); }); }); ``` - [ ] **Step 2: Run — expect failure** Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts` Expected: FAIL — "Cannot find module './payload-articles.repository'" - [ ] **Step 3: Implement payload-articles repository** ```typescript // packages/blog/src/infrastructure/repositories/payload-articles.repository.ts import "reflect-metadata"; import { injectable } from "inversify"; import { getPayload } from "payload"; import config from "@repo/core-cms"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import type { Article } from "@/entities/article"; type PayloadArticleDoc = { id: string | number; title?: string | null; slug?: string | null; content?: unknown; status?: string | null; author?: string | number | { id: string | number } | null; createdAt?: string | null; updatedAt?: string | null; }; function mapDoc(doc: PayloadArticleDoc): Article { const authorId = typeof doc.author === "object" && doc.author !== null ? String(doc.author.id) : doc.author != null ? String(doc.author) : ""; return { id: String(doc.id), title: doc.title ?? "", slug: doc.slug ?? "", content: doc.content ?? null, status: (doc.status === "published" ? "published" : "draft"), authorId, createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0), }; } @injectable() export class PayloadArticlesRepository implements IArticlesRepository { async getArticle(id: string): Promise
{ const payload = await getPayload({ config }); try { const doc = await payload.findByID({ collection: "articles", id, overrideAccess: false, }); return mapDoc(doc as PayloadArticleDoc); } catch { return undefined; } } async getArticleBySlug(slug: string): Promise
{ const payload = await getPayload({ config }); const result = await payload.find({ collection: "articles", where: { slug: { equals: slug } }, limit: 1, overrideAccess: false, }); const doc = result.docs[0] as PayloadArticleDoc | undefined; return doc ? mapDoc(doc) : undefined; } async getArticles(options?: { status?: string; authorId?: string; limit?: number; offset?: number; }): Promise { const payload = await getPayload({ config }); const where: Record = {}; if (options?.status) where.status = { equals: options.status }; if (options?.authorId) where.author = { equals: options.authorId }; const result = await payload.find({ collection: "articles", where, limit: options?.limit ?? 50, page: options?.offset ? Math.floor(options.offset / (options.limit ?? 50)) + 1 : 1, overrideAccess: false, }); return result.docs.map((d) => mapDoc(d as PayloadArticleDoc)); } async createArticle(input: Article): Promise
{ const payload = await getPayload({ config }); const created = await payload.create({ collection: "articles", data: { title: input.title, slug: input.slug, content: input.content, status: input.status, author: input.authorId, } as never, overrideAccess: false, }); return mapDoc(created as PayloadArticleDoc); } async updateArticle( id: string, input: Partial
, ): Promise
{ const payload = await getPayload({ config }); try { const updated = await payload.update({ collection: "articles", id, data: { ...(input.title !== undefined && { title: input.title }), ...(input.slug !== undefined && { slug: input.slug }), ...(input.content !== undefined && { content: input.content }), ...(input.status !== undefined && { status: input.status }), ...(input.authorId !== undefined && { author: input.authorId }), } as never, overrideAccess: false, }); return mapDoc(updated as PayloadArticleDoc); } catch { return undefined; } } } ``` > Note: `as never` casts on the Payload `data` arguments are needed because Payload's generated types (which we don't have for the empty-collection core-cms config) would normally constrain these. With empty `collections: []`, the generated types don't include `'articles'`, so we cast through `never`. After Plan 3 wires articles into core-cms.collections AND `pnpm generate:types` runs, these casts can potentially be removed — but for Plan 2's purpose (decoupled feature with deferred wiring) the casts are correct. > **Critical architectural note (revised after execution):** The repo takes `SanitizedConfig` via its constructor instead of `import config from '@repo/core-cms'` (which would create a workspace dependency cycle: blog ↔ core-cms). Constructor injection breaks the cycle at the package-graph level. The DI binding for production (Plan 5, app boot) supplies the config: > ```ts > import config from '@repo/core-cms' > blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository) > .toDynamicValue(() => new PayloadArticlesRepository(config)) > ``` > The blog `package.json` does NOT declare `@repo/core-cms` as a dependency. Apply this pattern to every payload-backed feature repository. - [ ] **Step 4: Run — expect pass** Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts` Expected: PASS — 2 tests. - [ ] **Step 5: Commit** ```bash git add packages/blog/src/infrastructure/repositories/payload-articles.repository.ts packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts git commit -m "feat(blog): add PayloadArticlesRepository with doc-to-entity mapping" ``` --- ## Phase 4: Interface-adapters (controllers) ### Task 2.9: Articles controller + tests **Files:** - Create: `packages/blog/src/interface-adapters/controllers/articles.controller.ts` - Create: `packages/blog/src/interface-adapters/controllers/articles.controller.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // packages/blog/src/interface-adapters/controllers/articles.controller.test.ts import { beforeEach, describe, expect, it } from "vitest"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import { InputParseError } from "@/entities/errors"; import { createArticleController, getArticlesController, getArticleBySlugController, } from "./articles.controller"; describe("articles controller", () => { let repo: MockArticlesRepository; beforeEach(() => { if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } repo = new MockArticlesRepository(); blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(repo); }); describe("createArticleController", () => { it("creates an article on valid input", async () => { const result = await createArticleController({ title: "Hello", content: "body", authorId: "u1", }); expect(result.title).toBe("Hello"); }); it("throws InputParseError on missing title", async () => { await expect( createArticleController({ content: "body", authorId: "u1" }), ).rejects.toBeInstanceOf(InputParseError); }); }); describe("getArticlesController", () => { it("returns array on valid input", async () => { const result = await getArticlesController({}); expect(result).toEqual([]); }); it("throws InputParseError on invalid input shape", async () => { await expect( getArticlesController({ limit: "not a number" } as unknown as Record< string, unknown >), ).rejects.toBeInstanceOf(InputParseError); }); }); describe("getArticleBySlugController", () => { it("returns undefined for missing slug", async () => { const result = await getArticleBySlugController({ slug: "nope" }); expect(result).toBeUndefined(); }); it("throws InputParseError on missing slug", async () => { await expect( getArticleBySlugController({} as { slug: string }), ).rejects.toBeInstanceOf(InputParseError); }); }); }); ``` - [ ] **Step 2: Run — expect failure** Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts` Expected: FAIL — "Cannot find module './articles.controller'" - [ ] **Step 3: Implement controller** ```typescript // packages/blog/src/interface-adapters/controllers/articles.controller.ts import { z } from "zod"; import { InputParseError } from "@/entities/errors"; import type { Article } from "@/entities/article"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; import { createArticleUseCase } from "@/application/use-cases/create-article.use-case"; const createInputSchema = z.object({ title: z.string().min(1).max(255), content: z.unknown(), authorId: z.string(), slug: z.string().optional(), }); const getInputSchema = z.object({ status: z.string().optional(), authorId: z.string().optional(), limit: z.number().optional(), offset: z.number().optional(), }); const getBySlugInputSchema = z.object({ slug: z.string().min(1), }); export async function createArticleController( input: Partial>, ): Promise
{ const parsed = createInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid create-article input", { cause: parsed.error, }); } return createArticleUseCase(parsed.data); } export async function getArticlesController( input: Partial>, ): Promise { const parsed = getInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-articles input", { cause: parsed.error, }); } return getArticlesUseCase(parsed.data); } export async function getArticleBySlugController(input: { slug: string; }): Promise
{ const parsed = getBySlugInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error, }); } const repo = blogContainer.get( BLOG_SYMBOLS.IArticlesRepository, ); return repo.getArticleBySlug(parsed.data.slug); } ``` > Note: `getArticleBySlugController` calls the repo directly rather than going through a separate `getArticleBySlug.use-case.ts`. This is acceptable per spec addendum v5 — for trivial pass-through reads, a use-case file would be empty ceremony. The pattern emerges if/when this read needs caching, authorization decisions, or other application logic. (Plan 3 leaves it as-is; create one in a later plan if behavior justifies it.) - [ ] **Step 4: Run — expect pass** Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts` Expected: PASS — 6 tests. - [ ] **Step 5: Commit** ```bash git add packages/blog/src/interface-adapters git commit -m "feat(blog): add articles controller with Zod validation" ``` --- ## Phase 5: Integrations (CMS schema + tRPC API) ### Task 2.10: Articles collection (simplified — no cross-feature relations) **Files:** - Create: `packages/blog/src/integrations/cms/collections/articles.ts` - Create: `packages/blog/src/integrations/cms/index.ts` - [ ] **Step 1: Implement collection (simplified — see schema simplifications section)** ```typescript // packages/blog/src/integrations/cms/collections/articles.ts import type { CollectionConfig } from "payload"; import { slugifyIfMissing } from "@repo/core-shared/payload"; export const articles: CollectionConfig = { slug: "articles", admin: { useAsTitle: "title", defaultColumns: ["title", "status", "author", "updatedAt"], }, hooks: { beforeChange: [slugifyIfMissing], }, versions: { drafts: true, }, fields: [ { name: "title", type: "text", required: true, maxLength: 255, }, { name: "slug", type: "text", unique: true, admin: { position: "sidebar", description: "Auto-generated from title if left empty", }, }, { name: "content", type: "richText", }, { name: "status", type: "select", options: [ { label: "Draft", value: "draft" }, { label: "Published", value: "published" }, ], defaultValue: "draft", required: true, admin: { position: "sidebar", }, }, // TODO(plan-3): Restore as `relationship → users` once auth feature is migrated. { name: "author", type: "text", required: true, admin: { position: "sidebar", description: "Temporary text field; restored to users relationship in Plan 3.", }, }, // TODO(plan-3): Restore `featuredImage: upload → media` once media feature is migrated. { name: "publishedAt", type: "date", admin: { position: "sidebar", date: { pickerAppearance: "dayAndTime", }, }, }, ], }; ``` > Note: `slugifyIfMissing` from `@repo/core-shared/payload` replaces the inline `autoGenerateSlug` hook from `packages/cms-core/src/collections/articles/hooks/before-change.ts`. Same behavior, generic helper. - [ ] **Step 2: Implement integrations/cms barrel** ```typescript // packages/blog/src/integrations/cms/index.ts export { articles } from "./collections/articles"; ``` - [ ] **Step 3: Verify it compiles** Run: `cd packages/blog && pnpm typecheck` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add packages/blog/src/integrations/cms git commit -m "feat(blog): add articles collection (simplified — no cross-feature refs)" ``` --- ### Task 2.11: Wire articles into core-cms composition **Files:** - Modify: `packages/core-cms/src/payload.config.ts` - Modify: `packages/core-cms/package.json` (add `@repo/blog` dependency) - [ ] **Step 1: Add @repo/blog to core-cms dependencies** Add inside `packages/core-cms/package.json`'s `dependencies` block: ```json "@repo/blog": "workspace:*", ``` > Note: This is the spec §4.2 composition exception — `core-cms` may import feature `/cms` exports. - [ ] **Step 2: Update payload.config.ts to register articles** Read current `packages/core-cms/src/payload.config.ts`. Replace it with: ```typescript import { buildConfig } from "payload"; import { postgresAdapter } from "@payloadcms/db-postgres"; import { lexicalEditor } from "@payloadcms/richtext-lexical"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { articles } from "@repo/blog/cms"; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); export default buildConfig({ editor: lexicalEditor(), collections: [articles], globals: [], secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/template", }, }), typescript: { outputFile: path.resolve(dirname, "generated-types.ts"), }, }); ``` - [ ] **Step 3: Install (picks up workspace dep)** Run: `pnpm install` Expected: install completes; `@repo/blog` available to `@repo/core-cms`. - [ ] **Step 4: Verify core-cms typechecks** Run: `pnpm typecheck --filter @repo/core-cms --filter @repo/blog` Expected: PASS for both. - [ ] **Step 5: Regenerate Payload types** (now articles is in the config; types should include `Article`-shaped doc) Run: `cd apps/cms && pnpm generate:types` Expected: writes new `packages/core-cms/src/generated-types.ts` containing an `Articles` interface. - [ ] **Step 6: Verify generated types compile** Run: `pnpm typecheck --filter @repo/core-cms` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add packages/core-cms packages/blog pnpm-lock.yaml git commit -m "feat(core-cms): compose @repo/blog/cms into payload config" ``` --- ### Task 2.12: tRPC router (integrations/api) **Files:** - Create: `packages/blog/src/integrations/api/router.ts` - Create: `packages/blog/src/integrations/api/router.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // packages/blog/src/integrations/api/router.test.ts import { beforeEach, describe, expect, it } from "vitest"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import { blogRouter } from "./router"; describe("blogRouter", () => { let repo: MockArticlesRepository; beforeEach(() => { if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } repo = new MockArticlesRepository(); blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(repo); }); it("exposes articleBySlug, listArticles, createArticle procedures", () => { const procedureNames = Object.keys(blogRouter._def.procedures); expect(procedureNames).toContain("articleBySlug"); expect(procedureNames).toContain("listArticles"); expect(procedureNames).toContain("createArticle"); }); it("articleBySlug returns the article when present", async () => { const now = new Date(); await repo.createArticle({ id: "1", title: "T", slug: "t", content: null, status: "draft", authorId: "u1", createdAt: now, updatedAt: now, }); const caller = blogRouter.createCaller({}); const result = await caller.articleBySlug({ slug: "t" }); expect(result?.id).toBe("1"); }); it("listArticles returns all articles when no input is given", async () => { const caller = blogRouter.createCaller({}); const result = await caller.listArticles(); expect(result).toEqual([]); }); }); ``` - [ ] **Step 2: Run — expect failure** Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts` Expected: FAIL — "Cannot find module './router'" - [ ] **Step 3: Implement router** ```typescript // packages/blog/src/integrations/api/router.ts import { z } from "zod"; import { router, publicProcedure } from "@repo/core-shared/trpc/init"; import { createArticleController, getArticlesController, getArticleBySlugController, } from "@/interface-adapters/controllers/articles.controller"; export const blogRouter = router({ articleBySlug: publicProcedure .input(z.object({ slug: z.string().min(1) })) .query(({ input }) => getArticleBySlugController(input)), listArticles: publicProcedure .input( z .object({ status: z.string().optional(), authorId: z.string().optional(), limit: z.number().optional(), offset: z.number().optional(), }) .optional(), ) .query(({ input }) => getArticlesController(input ?? {})), createArticle: publicProcedure .input( z.object({ title: z.string().min(1).max(255), content: z.unknown(), authorId: z.string(), slug: z.string().optional(), }), ) .mutation(({ input }) => createArticleController(input)), }); export type BlogRouter = typeof blogRouter; ``` - [ ] **Step 4: Run — expect pass** Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add packages/blog/src/integrations/api git commit -m "feat(blog): add tRPC router with articleBySlug + listArticles + createArticle" ``` --- ### Task 2.13: Wire blogRouter into core-api **Files:** - Modify: `packages/core-api/src/root.ts` - Modify: `packages/core-api/package.json` (add `@repo/blog`) - [ ] **Step 1: Add @repo/blog to core-api dependencies** Add inside `packages/core-api/package.json`'s `dependencies` block: ```json "@repo/blog": "workspace:*", ``` - [ ] **Step 2: Update root.ts** Replace `packages/core-api/src/root.ts` with: ```typescript import { router } from "@repo/core-shared/trpc/init"; import { blogRouter } from "@repo/blog/api"; export const appRouter = router({ blog: blogRouter, }); export type AppRouter = typeof appRouter; ``` - [ ] **Step 3: Install + verify** Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/blog` Expected: both PASS. - [ ] **Step 4: Commit** ```bash git add packages/core-api packages/blog pnpm-lock.yaml git commit -m "feat(core-api): compose @repo/blog/api into appRouter under 'blog' namespace" ``` --- ## Phase 6: UI helpers (deferred render — apps wire in Plan 5) ### Task 2.14: ui/query.ts helper **Files:** - Create: `packages/blog/src/ui/query.ts` - [ ] **Step 1: Implement** (no test — exercised when apps consume in Plan 5) ```typescript // packages/blog/src/ui/query.ts // React Query option builders for blog feature procedures. // Consumed by apps via the @repo/core-trpc client (wired in Plan 5). // // Example consumer (Plan 5): // import { useQuery } from '@tanstack/react-query' // import { trpc } from '@repo/core-trpc' // import { articleBySlugQuery } from '@repo/blog/src/ui/query' // const { data } = useQuery(articleBySlugQuery(trpc, slug)) // // Kept framework-agnostic here: takes the typed `trpc` client as an argument // rather than importing it. This avoids importing @repo/core-trpc (which is // a frontend platform package — feature shouldn't depend on it). type TrpcClient = { blog: { articleBySlug: { queryOptions: (input: { slug: string }) => unknown; }; listArticles: { queryOptions: (input?: { status?: string; authorId?: string; limit?: number; offset?: number; }) => unknown; }; }; }; export function articleBySlugQuery(client: TrpcClient, slug: string) { return client.blog.articleBySlug.queryOptions({ slug }); } export function listArticlesQuery( client: TrpcClient, options?: { status?: string; authorId?: string; limit?: number; offset?: number }, ) { return client.blog.listArticles.queryOptions(options); } ``` > Note: `TrpcClient` is locally typed structurally because `@repo/core-trpc` doesn't ship the React-aware client until Plan 5. The structural type is sufficient: when Plan 5 lands and apps call `articleBySlugQuery(trpc, slug)` with the real typed client, structural compatibility holds. Plan 5 may revise this to import from `@repo/core-trpc` once that's available. - [ ] **Step 2: Verify it compiles** Run: `cd packages/blog && pnpm typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/blog/src/ui git commit -m "feat(blog): add ui/query.ts (framework-agnostic React Query option builders)" ``` --- ## Phase 7: Wiring + barrel + feature test ### Task 2.15: Wire blog package barrel **Files:** - Modify: `packages/blog/src/index.ts` - [ ] **Step 1: Replace empty index.ts** ```typescript // packages/blog/src/index.ts export type { Article, ArticleStatus } from "./entities/article"; export { ArticleNotFoundError, InputParseError } from "./entities/errors"; export { articleBySlugQuery, listArticlesQuery } from "./ui/query"; ``` - [ ] **Step 2: Verify it compiles** Run: `cd packages/blog && pnpm typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add packages/blog/src/index.ts git commit -m "feat(blog): wire root barrel (entity types + ui query helpers)" ``` --- ### Task 2.16: Feature-level test (cross-layer) **Files:** - Create: `packages/blog/tests/articles.feature.test.ts` - [ ] **Step 1: Write the test** ```typescript // packages/blog/tests/articles.feature.test.ts // // Feature-level test: exercises the full slice // tRPC procedure -> controller -> use-case -> mock repo // without going through a network or the actual Payload Local API. // Verifies that the layers are correctly wired through the per-feature DI container. import { beforeEach, describe, expect, it } from "vitest"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface"; import { blogRouter } from "@/integrations/api/router"; describe("blog feature: article-by-slug end-to-end", () => { let repo: MockArticlesRepository; beforeEach(() => { if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } repo = new MockArticlesRepository(); blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(repo); }); it("creates an article via tRPC, then fetches it back by slug", async () => { const caller = blogRouter.createCaller({}); const created = await caller.createArticle({ title: "The Vertical Refactor", content: { type: "doc", children: [] }, authorId: "u1", slug: "vertical-refactor", }); expect(created.id).toBeTruthy(); expect(created.slug).toBe("vertical-refactor"); const fetched = await caller.articleBySlug({ slug: "vertical-refactor" }); expect(fetched?.id).toBe(created.id); expect(fetched?.title).toBe("The Vertical Refactor"); }); it("listArticles filters by status", async () => { const caller = blogRouter.createCaller({}); await caller.createArticle({ title: "Draft One", content: null, authorId: "u1", }); const draftOnly = await caller.listArticles({ status: "draft" }); expect(draftOnly).toHaveLength(1); const publishedOnly = await caller.listArticles({ status: "published" }); expect(publishedOnly).toHaveLength(0); }); }); ``` - [ ] **Step 2: Verify the path alias works for tests/ folder** The test uses `@/` paths. Confirm `packages/blog/tsconfig.json` includes `"tests/**/*"` (it does per Task 2.1). - [ ] **Step 3: Run** Run: `cd packages/blog && pnpm vitest run tests/` Expected: PASS — 2 tests. - [ ] **Step 4: Commit** ```bash git add packages/blog/tests git commit -m "test(blog): add feature-level test exercising router → controller → use-case → repo" ``` --- ### Task 2.17: Final verification + apps/cms boot smoke test - [ ] **Step 1: Run all blog tests** Run: `pnpm test --filter @repo/blog` Expected: PASS — exact count: entities (7) + di container (2) + use-cases (2 + 2) + payload-articles repo (2) + controller (6) + router (3) + feature (2) = 26 tests. - [ ] **Step 2: Run repo-wide typecheck** Run: `pnpm typecheck` Expected: PASS for all the packages we touched. Pre-existing failures in `@repo/api` and `@repo/ui` remain (will be addressed in Plans 5/6). - [ ] **Step 3: Boot apps/cms to verify articles collection registers** Ensure Postgres is running (`docker compose up -d postgres`). Run: `pnpm dev --filter @repo/cms` (in background; wait ~10s for "Ready"). Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin` Expected: `200`. Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles` Expected: `200`. Stop the dev server. > Note: When the dev server first connects, Payload may again offer to push schema changes (drop the OLD `users` and `media` columns since core-cms now only knows about `articles` — those collections come back in Plan 3). Answer N to the prompt or accept — for the smoke test we only care that admin responds 200, so the dev server can stay paused on the prompt. - [ ] **Step 4: Regenerate types one more time and confirm it includes Articles** Run: `cd apps/cms && pnpm generate:types` Run: `grep -c "Article" packages/core-cms/src/generated-types.ts` Expected: at least 5 occurrences (Articles interface, references in Config, etc.). - [ ] **Step 5: Commit regenerated types** ```bash git add packages/core-cms/src/generated-types.ts git commit -m "feat(core-cms): regenerate types — now includes Articles" ``` --- ## Plan 2 Done Criteria - [ ] All 26 tests in `@repo/blog` pass - [ ] `@repo/blog`, `@repo/core-cms`, `@repo/core-api` all typecheck - [ ] `apps/cms` admin UI serves `/admin` and `/admin/collections/articles` with 200 - [ ] `pnpm generate:types` in `apps/cms` produces a `generated-types.ts` containing `Articles` - [ ] `core-cms/src/payload.config.ts` references `@repo/blog/cms` (not the old `cms-core`'s articles) - [ ] `core-api/src/root.ts` references `@repo/blog/api` and exposes `blog.*` procedures - [ ] `tsconfig.base.json` includes the three `@repo/blog*` aliases - [ ] No deletions yet — `packages/core/src/entities/models/article.ts` and friends still exist (drained in Plan 6) **Next plan:** Plan 3 — Auth + Media features. Migrates the Users collection (auth feature) and Media collection (media feature). Restores `articles.author` to a `relationship → users` and adds back `featuredImage: upload → media`. Demonstrates that the per-feature DI pattern scales beyond one feature.