diff --git a/AGENTS.md b/AGENTS.md index 114825c..b12315e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,6 +205,7 @@ await bindProductionUsers(authContainer, payloadConfig); - **Dependency Flow** — `docs/architecture/dependency-flow.md` — allowed directions and composition pattern - **Adding a Feature Guide** — `docs/guides/adding-a-feature.md` — step-by-step new feature walkthrough - **Testing Strategy** — `docs/guides/testing-strategy.md` — test placement, Vitest per-package, Playwright e2e +- **TDD Workflow** — `docs/guides/tdd-workflow.md` — red-green-refactor cycle, mocking decision tree, coverage targets Per-package documentation lives in each `AGENTS.md`: - `packages/core-shared/AGENTS.md` diff --git a/CLAUDE.md b/CLAUDE.md index 184b093..809c41f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,17 @@ pnpm turbo boundaries # Validate workspace dependency graph docker compose up -d # Start PostgreSQL ``` +## TDD + +```bash +pnpm test --watch --filter @repo/ # watch one feature +pnpm test -- --coverage # full run with coverage +pnpm test:stories # Storybook smoke tests +pnpm test:e2e # Playwright e2e +``` + +See `docs/guides/tdd-workflow.md` for the full cycle. + ## Project Overview Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, `blog`, `media`, `marketing-pages`, `navigation`) owns its Clean Architecture layers. Core packages (`core-shared`, `core-cms`, `core-api`, `core-trpc`, `core-ui`) provide foundation. Two tooling packages (`core-eslint`, `core-typescript`) provide shared configs. Workspace boundaries are enforced by ESLint (lint-time) and Turborepo (build-graph time). Supports Next.js and TanStack Start as frontend frameworks, Payload CMS for content management, and comprehensive agent-optimized documentation. diff --git a/docs/guides/adding-a-feature.md b/docs/guides/adding-a-feature.md index 54c213d..2d25f5d 100644 --- a/docs/guides/adding-a-feature.md +++ b/docs/guides/adding-a-feature.md @@ -4,6 +4,9 @@ A feature is a vertical slice: entities, use cases, repositories, tRPC router, C Decide upfront: **Is this a new feature or an extension of an existing one?** New features get a new package (e.g., `packages/comments`). Extensions add to an existing feature (e.g., adding an `unapprove-article` procedure to `packages/blog`). +> **TDD Order Required:** You may not advance to the next layer until the +> current layer's tests are red, then green. See [TDD Workflow](./tdd-workflow.md). + ## Part 1: New Feature Scaffold ### Step 1: Decide shape @@ -21,7 +24,7 @@ The smallest viable feature has: ### Step 2: Create the package ```bash -mkdir -p packages//src/{entities,application/{use-cases,repositories},infrastructure/repositories,di,integrations/{api,cms},ui,interface-adapters/controllers} +mkdir -p packages//src/{entities,application/{use-cases,repositories},infrastructure/repositories,di,integrations/{api,cms},ui,interface-adapters/controllers,__factories__,__contracts__} ``` ### Step 3: Create `package.json` @@ -51,7 +54,8 @@ mkdir -p packages//src/{entities,application/{use-cases,repositori } }, "dependencies": { - "@repo/core-shared": "workspace:*" + "@repo/core-shared": "workspace:*", + "@repo/core-testing": "workspace:*" }, "devDependencies": { "@repo/core-typescript": "workspace:*" @@ -71,7 +75,7 @@ mkdir -p packages//src/{entities,application/{use-cases,repositori "jsx": "preserve" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "dist"] } ``` @@ -104,290 +108,828 @@ packages: Run `pnpm install` — the new package is now part of the workspace. -## Part 2: Build the Layers +--- -### Entities: Define types +## Part 2: Build the Layers (Test-First) -Create `packages//src/entities/model.ts`: +The steps below use `comments` as the example feature name. Replace `comments`/`Comment`/`comment` with your feature name throughout. + +--- + +### Step 1: Write failing test for entity schema + +Create `packages/comments/src/entities/comment.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { commentSchema } from "./comment"; + +describe("commentSchema", () => { + it("accepts a valid comment", () => { + const result = commentSchema.parse({ + id: "c-1", + articleId: "a-1", + body: "Great post", + authorId: "u-1", + createdAt: new Date(), + }); + expect(result.body).toBe("Great post"); + }); + + it("rejects empty body", () => { + expect(() => + commentSchema.parse({ + id: "c-1", + articleId: "a-1", + body: "", + authorId: "u-1", + createdAt: new Date(), + }), + ).toThrow(); + }); +}); +``` + +Run — confirm RED: + +```bash +pnpm test --filter @repo/comments -- comment.test.ts +# Error: Cannot find module './comment' +``` + +--- + +### Step 2: Implement entity to pass + +Create `packages/comments/src/entities/comment.ts`: ```typescript import { z } from "zod"; -export const modelSchema = z.object({ +export const commentSchema = z.object({ id: z.string(), - name: z.string().min(1).max(255), + articleId: z.string(), + body: z.string().min(1).max(2000), + authorId: z.string(), createdAt: z.date(), }); -export type Model = z.infer; +export type Comment = z.infer; ``` -Create `packages//src/entities/index.ts`: +Create `packages/comments/src/entities/index.ts`: ```typescript -export { modelSchema, type Model } from "./model.js"; +export { commentSchema, type Comment } from "./comment.js"; ``` -### Use cases: Implement business logic +Run — confirm GREEN: -Create `packages//src/application/use-cases/create-model.use-case.ts`: +```bash +pnpm test --filter @repo/comments -- comment.test.ts +# PASS ✓ accepts a valid comment +# PASS ✓ rejects empty body +``` + +--- + +### Step 3: Write factory + +Create `packages/comments/src/__factories__/comment.factory.ts`: ```typescript -import { injectable, inject } from "inversify"; -import type { IModelsRepository } from "../repositories/models.repository.interface.js"; -import { MODELS_REPOSITORY } from "../../di/symbols.js"; -import type { Model } from "../../entities/index.js"; +import { defineFactory } from "@repo/core-testing/factory"; +import type { Comment } from "../entities/comment.js"; + +export const commentFactory = defineFactory(({ sequence }) => ({ + id: `comment-${sequence}`, + articleId: "article-1", + body: `Comment body ${sequence}`, + authorId: "user-1", + createdAt: new Date("2026-01-01T00:00:00Z"), +})); +``` + +Create `packages/comments/src/__factories__/index.ts`: + +```typescript +export { commentFactory } from "./comment.factory.js"; +``` + +--- + +### Step 4: Write failing test for use case (using factory + mock repo) + +The use case depends on `ICommentsRepository`. Write the test before the repository exists; use a hand-rolled inline mock for now. + +Create `packages/comments/src/application/use-cases/create-comment.use-case.test.ts`: + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { commentFactory } from "../../__factories__/comment.factory"; +import { createCommentUseCase } from "./create-comment.use-case"; +import { commentsContainer } from "../../di/container"; +import { COMMENTS_SYMBOLS } from "../../di/symbols"; +import type { ICommentsRepository } from "../repositories/comments-repository.interface"; +import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository"; + +describe("createCommentUseCase", () => { + let repo: MockCommentsRepository; + + beforeEach(() => { + if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) { + commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository); + } + repo = new MockCommentsRepository(); + commentsContainer + .bind(COMMENTS_SYMBOLS.ICommentsRepository) + .toConstantValue(repo); + commentFactory.reset(); + }); + + it("creates a comment with the correct fields", async () => { + const result = await createCommentUseCase({ + articleId: "a-1", + body: "Great post", + authorId: "u-1", + }); + expect(result.body).toBe("Great post"); + expect(result.articleId).toBe("a-1"); + expect(result.authorId).toBe("u-1"); + expect(typeof result.id).toBe("string"); + }); + + it("throws when body is empty", async () => { + await expect( + createCommentUseCase({ articleId: "a-1", body: "", authorId: "u-1" }), + ).rejects.toThrow(); + }); +}); +``` + +Run — confirm RED: + +```bash +pnpm test --filter @repo/comments -- create-comment.use-case.test.ts +# Error: Cannot find module './create-comment.use-case' +``` + +--- + +### Step 5: Implement use case to pass + +Create `packages/comments/src/di/symbols.ts`: + +```typescript +export const COMMENTS_SYMBOLS = { + ICommentsRepository: Symbol("ICommentsRepository"), +} as const; +``` + +Create `packages/comments/src/application/repositories/comments-repository.interface.ts`: + +```typescript +import type { Comment } from "../../entities/comment.js"; + +export interface ICommentsRepository { + createComment(comment: Comment): Promise; + getCommentsForArticle(articleId: string): Promise; +} +``` + +Create `packages/comments/src/application/use-cases/create-comment.use-case.ts`: + +```typescript +import type { Comment } from "../../entities/comment.js"; +import { commentSchema } from "../../entities/comment.js"; +import { commentsContainer } from "../../di/container.js"; +import { COMMENTS_SYMBOLS } from "../../di/symbols.js"; +import type { ICommentsRepository } from "../repositories/comments-repository.interface.js"; + +export async function createCommentUseCase(input: { + articleId: string; + body: string; + authorId: string; +}): Promise { + const repo = commentsContainer.get( + COMMENTS_SYMBOLS.ICommentsRepository, + ); + const comment: Comment = { + id: crypto.randomUUID(), + articleId: input.articleId, + body: input.body, + authorId: input.authorId, + createdAt: new Date(), + }; + commentSchema.parse(comment); // throws on invalid body + return repo.createComment(comment); +} +``` + +Run — confirm GREEN: + +```bash +pnpm test --filter @repo/comments -- create-comment.use-case.test.ts +# PASS ✓ creates a comment with the correct fields +# PASS ✓ throws when body is empty +``` + +--- + +### Step 6: Write contract suite + +Create `packages/comments/src/__contracts__/comments-repository.contract.ts`: + +```typescript +import { it, expect, beforeEach } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js"; +import { commentFactory } from "../__factories__/comment.factory.js"; + +export const commentsRepositoryContract = + defineContractSuite( + "ICommentsRepository", + ({ buildSubject }) => { + let repo: ICommentsRepository; + + beforeEach(async () => { + commentFactory.reset(); + repo = await buildSubject(); + }); + + it("createComment returns a comment with the correct fields", async () => { + const seed = commentFactory.build({ body: "Hello" }); + const created = await repo.createComment(seed); + expect(typeof created.id).toBe("string"); + expect(created.body).toBe("Hello"); + }); + + it("getCommentsForArticle returns comments for the given articleId", async () => { + const seed = commentFactory.build({ articleId: "a-1" }); + await repo.createComment(seed); + const results = await repo.getCommentsForArticle("a-1"); + expect(results).toHaveLength(1); + expect(results[0]?.articleId).toBe("a-1"); + }); + + it("getCommentsForArticle returns empty array for unknown articleId", async () => { + const results = await repo.getCommentsForArticle("no-such-article"); + expect(results).toHaveLength(0); + }); + }, + ); +``` + +--- + +### Step 7: Implement Mock repo, run contract → green + +Create `packages/comments/src/infrastructure/repositories/mock-comments.repository.ts`: + +```typescript +import "reflect-metadata"; +import { injectable } from "inversify"; +import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface.js"; +import type { Comment } from "../../entities/comment.js"; @injectable() -export class CreateModelUseCase { - constructor( - @inject(MODELS_REPOSITORY) private repo: IModelsRepository, - ) {} +export class MockCommentsRepository implements ICommentsRepository { + private _comments: Comment[] = []; - async execute(input: { name: string }): Promise { - return this.repo.create({ - id: crypto.randomUUID(), - name: input.name, - createdAt: new Date(), + async createComment(comment: Comment): Promise { + this._comments.push(comment); + return comment; + } + + async getCommentsForArticle(articleId: string): Promise { + return this._comments.filter((c) => c.articleId === articleId); + } +} +``` + +Create `packages/comments/src/infrastructure/repositories/mock-comments.repository.test.ts`: + +```typescript +import { describe } from "vitest"; +import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract"; +import { MockCommentsRepository } from "./mock-comments.repository"; + +describe("MockCommentsRepository", () => { + commentsRepositoryContract.run(async () => new MockCommentsRepository()); +}); +``` + +Run — confirm GREEN: + +```bash +pnpm test --filter @repo/comments -- mock-comments.repository.test.ts +# PASS Contract: ICommentsRepository +# ✓ createComment returns a comment with the correct fields +# ✓ getCommentsForArticle returns comments for the given articleId +# ✓ getCommentsForArticle returns empty array for unknown articleId +``` + +--- + +### Step 8: Implement Payload repo (with vi.mock), run same contract → green + +Create `packages/comments/src/infrastructure/repositories/payload-comments.repository.ts`: + +```typescript +import "reflect-metadata"; +import { injectable } from "inversify"; +import { getPayload } from "payload"; +import type { SanitizedConfig } from "payload"; +import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface.js"; +import type { Comment } from "../../entities/comment.js"; + +type PayloadCommentDoc = { + id: string | number; + articleId?: string | null; + body?: string | null; + author?: string | number | null; + createdAt?: string | null; +}; + +function mapDoc(doc: PayloadCommentDoc): Comment { + return { + id: String(doc.id), + articleId: doc.articleId ?? "", + body: doc.body ?? "", + authorId: doc.author != null ? String(doc.author) : "", + createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), + }; +} + +@injectable() +export class PayloadCommentsRepository implements ICommentsRepository { + constructor(private config: SanitizedConfig) {} + + async createComment(comment: Comment): Promise { + const payload = await getPayload({ config: this.config }); + const created = await payload.create({ + collection: "comments", + data: { + articleId: comment.articleId, + body: comment.body, + author: comment.authorId, + } as never, + overrideAccess: true, + }); + return mapDoc(created as PayloadCommentDoc); + } + + async getCommentsForArticle(articleId: string): Promise { + const payload = await getPayload({ config: this.config }); + const result = await payload.find({ + collection: "comments", + where: { articleId: { equals: articleId } } as never, + overrideAccess: true, + }); + return result.docs.map((d) => mapDoc(d as PayloadCommentDoc)); + } +} +``` + +Create `packages/comments/src/infrastructure/repositories/payload-comments.repository.test.ts`: + +```typescript +import { describe, vi } from "vitest"; +import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract"; +import { PayloadCommentsRepository } from "./payload-comments.repository"; +import { stubPayloadConfig } from "@repo/core-testing/payload"; + +vi.mock("payload", () => ({ getPayload: vi.fn() })); + +describe("PayloadCommentsRepository", () => { + commentsRepositoryContract.run(async () => { + const store = new Map>(); + const stub = { + create: vi.fn(async ({ data }: { collection: string; data: Record }) => { + const doc = { id: `stub-${store.size + 1}`, ...data }; + store.set(String(doc.id), doc); + return doc; + }), + find: vi.fn(async ({ where }: { collection: string; where?: { articleId?: { equals: string } } }) => { + let docs = Array.from(store.values()); + if (where?.articleId) { + docs = docs.filter((d) => d.articleId === where.articleId?.equals); + } + return { docs }; + }), + }; + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + return new PayloadCommentsRepository(stubPayloadConfig); + }); +}); +``` + +Run — confirm GREEN: + +```bash +pnpm test --filter @repo/comments -- payload-comments.repository.test.ts +# PASS PayloadCommentsRepository > Contract: ICommentsRepository +# ✓ createComment returns a comment with the correct fields +# ✓ getCommentsForArticle returns comments for the given articleId +# ✓ getCommentsForArticle returns empty array for unknown articleId +``` + +--- + +### Step 9: Write failing controller test + +Create `packages/comments/src/interface-adapters/controllers/comments.controller.test.ts`: + +```typescript +import { beforeEach, describe, expect, it } from "vitest"; +import { commentsContainer } from "../../di/container"; +import { COMMENTS_SYMBOLS } from "../../di/symbols"; +import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository"; +import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface"; +import { InputParseError } from "../../entities/errors"; +import { createCommentController } from "./comments.controller"; + +describe("comments controller", () => { + beforeEach(() => { + if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) { + commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository); + } + commentsContainer + .bind(COMMENTS_SYMBOLS.ICommentsRepository) + .toConstantValue(new MockCommentsRepository()); + }); + + describe("createCommentController", () => { + it("creates a comment on valid input", async () => { + const result = await createCommentController({ + articleId: "a-1", + body: "Nice article", + authorId: "u-1", + }); + expect(result.body).toBe("Nice article"); + }); + + it("throws InputParseError when body is missing", async () => { + await expect( + createCommentController({ articleId: "a-1", authorId: "u-1" }), + ).rejects.toBeInstanceOf(InputParseError); + }); + }); +}); +``` + +Run — confirm RED: + +```bash +pnpm test --filter @repo/comments -- comments.controller.test.ts +# Error: Cannot find module './comments.controller' +``` + +--- + +### Step 10: Implement controller + +Create `packages/comments/src/entities/errors.ts`: + +```typescript +export class InputParseError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "InputParseError"; + } +} +``` + +Create `packages/comments/src/interface-adapters/controllers/comments.controller.ts`: + +```typescript +import { z } from "zod"; +import { InputParseError } from "../../entities/errors.js"; +import { createCommentUseCase } from "../../application/use-cases/create-comment.use-case.js"; +import type { Comment } from "../../entities/comment.js"; + +const createInputSchema = z.object({ + articleId: z.string().min(1), + body: z.string().min(1), + authorId: z.string().min(1), +}); + +export async function createCommentController( + input: Partial>, +): Promise { + const parsed = createInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid create-comment input", { + cause: parsed.error, }); } + return createCommentUseCase(parsed.data); } ``` -### Repository interface and mock +Run — confirm GREEN: -Create `packages//src/application/repositories/models.repository.interface.ts`: - -```typescript -import type { Model } from "../../entities/index.js"; - -export interface IModelsRepository { - create(model: Model): Promise; - getById(id: string): Promise; -} +```bash +pnpm test --filter @repo/comments -- comments.controller.test.ts +# PASS ✓ creates a comment on valid input +# PASS ✓ throws InputParseError when body is missing ``` -Create `packages//src/infrastructure/repositories/mock-models.repository.ts`: +--- + +### Step 11: Write failing tRPC integration test + +Create `packages/comments/src/integrations/api/router.test.ts`: ```typescript -import { injectable } from "inversify"; -import type { IModelsRepository } from "../../application/repositories/models.repository.interface.js"; -import type { Model } from "../../entities/index.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { commentsContainer } from "../../di/container"; +import { COMMENTS_SYMBOLS } from "../../di/symbols"; +import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository"; +import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface"; +import { commentsRouter } from "./router"; -@injectable() -export class MockModelsRepository implements IModelsRepository { - private store: Map = new Map(); +describe("commentsRouter", () => { + beforeEach(() => { + if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) { + commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository); + } + commentsContainer + .bind(COMMENTS_SYMBOLS.ICommentsRepository) + .toConstantValue(new MockCommentsRepository()); + }); - async create(model: Model): Promise { - this.store.set(model.id, model); - return model; - } + it("exposes createComment procedure", () => { + const procedureNames = Object.keys(commentsRouter._def.procedures); + expect(procedureNames).toContain("createComment"); + }); - async getById(id: string): Promise { - return this.store.get(id) ?? null; - } -} + it("createComment creates and returns the comment", async () => { + const caller = commentsRouter.createCaller({}); + const result = await caller.createComment({ + articleId: "a-1", + body: "Hello", + authorId: "u-1", + }); + expect(result.body).toBe("Hello"); + }); +}); ``` -### DI container: Wire everything +Run — confirm RED: -Create `packages//src/di/symbols.ts`: - -```typescript -export const MODELS_REPOSITORY = Symbol("IModelsRepository"); +```bash +pnpm test --filter @repo/comments -- router.test.ts +# Error: Cannot find module './router' ``` -Create `packages//src/di/container.ts`: +--- + +### Step 12: Wire router + +Create `packages/comments/src/di/container.ts`: ```typescript +import "reflect-metadata"; import { Container } from "inversify"; -import { MockModelsRepository } from "../infrastructure/repositories/mock-models.repository.js"; -import { CreateModelUseCase } from "../application/use-cases/create-model.use-case.js"; -import { MODELS_REPOSITORY } from "./symbols.js"; -import type { IModelsRepository } from "../application/repositories/models.repository.interface.js"; +import { MockCommentsRepository } from "../infrastructure/repositories/mock-comments.repository.js"; +import { COMMENTS_SYMBOLS } from "./symbols.js"; +import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js"; -export function createContainer(): Container { - const container = new Container({ defaultScope: "Singleton" }); - - container.bind(MODELS_REPOSITORY).to(MockModelsRepository); - container.bind(CreateModelUseCase).toSelf(); - - return container; -} - -export const container = createContainer(); +export const commentsContainer = new Container({ defaultScope: "Singleton" }); +commentsContainer + .bind(COMMENTS_SYMBOLS.ICommentsRepository) + .to(MockCommentsRepository); ``` -Create `packages//src/di/bind-production.ts` (if using Payload): +Create `packages/comments/src/integrations/api/router.ts`: ```typescript -import type { Container } from "inversify"; -import type { Config as PayloadConfig } from "payload"; -import { PayloadModelsRepository } from "../infrastructure/repositories/payload-models.repository.js"; -import { MODELS_REPOSITORY } from "./symbols.js"; -import type { IModelsRepository } from "../application/repositories/models.repository.interface.js"; +import { z } from "zod"; +import { t } from "@repo/core-shared/trpc/init"; +import { createCommentController } from "../../interface-adapters/controllers/comments.controller.js"; -export async function bindProductionModels( - container: Container, - config: PayloadConfig, -): Promise { - const repo = new PayloadModelsRepository(config); - container.rebind(MODELS_REPOSITORY).toConstantValue(repo); +export const commentsRouter = t.router({ + createComment: t.procedure + .input( + z.object({ + articleId: z.string().min(1), + body: z.string().min(1), + authorId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + return createCommentController(input); + }), +}); +``` + +Create `packages/comments/src/integrations/api/index.ts`: + +```typescript +export { commentsRouter } from "./router.js"; +``` + +Run — confirm GREEN: + +```bash +pnpm test --filter @repo/comments -- router.test.ts +# PASS ✓ exposes createComment procedure +# PASS ✓ createComment creates and returns the comment +``` + +--- + +### Step 13: (UI optional) Write failing component test with renderWithProviders + +Create `packages/comments/src/ui/CommentForm.test.tsx`: + +```typescript +import { describe, expect, it } from "vitest"; +import { renderWithProviders } from "@repo/core-testing/react"; +import { CommentForm } from "./CommentForm"; + +describe("CommentForm", () => { + it("renders a textarea and submit button", () => { + const screen = renderWithProviders( + {}} />, + ); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /post comment/i })).toBeInTheDocument(); + }); +}); +``` + +Run — confirm RED: + +```bash +pnpm test --filter @repo/comments -- CommentForm.test.tsx +# Error: Cannot find module './CommentForm' +``` + +--- + +### Step 14: Implement component + +Create `packages/comments/src/ui/CommentForm.tsx`: + +```typescript +import { useState } from "react"; + +interface CommentFormProps { + articleId: string; + authorId: string; + onSuccess: () => void; +} + +export function CommentForm({ articleId, authorId, onSuccess }: CommentFormProps) { + const [body, setBody] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + // Wire to tRPC mutation in a real implementation + onSuccess(); + } + + return ( +
+