diff --git a/docs/superpowers/plans/2026-04-06-plan-2-core-package.md b/docs/superpowers/plans/2026-04-06-plan-2-core-package.md new file mode 100644 index 0000000..52dcd88 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-plan-2-core-package.md @@ -0,0 +1,1639 @@ +# Plan 2: Core Package + DI — Implementation Plan + +> **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:** Implement the `@repo/core` clean architecture package with entities, application interfaces, use cases, mock infrastructure, InversifyJS DI, controllers, and unit tests — all following TDD. + +**Architecture:** Single `@repo/core` package with 5 layers (entities → application → infrastructure → interface-adapters → di). Dependencies point inward only. InversifyJS resolves interfaces to implementations at runtime. Mock implementations enable testing without external services. Use cases call `getInjection()` to resolve dependencies — they never import infrastructure directly. + +**Tech Stack:** TypeScript 5.x, InversifyJS 6.x, reflect-metadata, Zod, Vitest + +--- + +## File Map + +### Entities Layer (zero deps) +| File | Responsibility | +|---|---| +| `packages/core/src/entities/models/user.ts` | User Zod schema + type | +| `packages/core/src/entities/models/article.ts` | Article Zod schema + type | +| `packages/core/src/entities/models/session.ts` | Session Zod schema + type | +| `packages/core/src/entities/models/cookie.ts` | Cookie type | +| `packages/core/src/entities/models/index.ts` | Re-exports all models | +| `packages/core/src/entities/errors/auth.ts` | AuthenticationError, UnauthenticatedError, UnauthorizedError | +| `packages/core/src/entities/errors/common.ts` | NotFoundError, InputParseError | +| `packages/core/src/entities/errors/index.ts` | Re-exports all errors | +| `packages/core/src/entities/index.ts` | Re-exports models + errors | + +### Application Layer (imports entities only) +| File | Responsibility | +|---|---| +| `packages/core/src/application/repositories/users.repository.interface.ts` | IUsersRepository | +| `packages/core/src/application/repositories/articles.repository.interface.ts` | IArticlesRepository | +| `packages/core/src/application/repositories/index.ts` | Re-exports | +| `packages/core/src/application/services/auth.service.interface.ts` | IAuthenticationService | +| `packages/core/src/application/services/telemetry.service.interface.ts` | ITelemetryService | +| `packages/core/src/application/services/index.ts` | Re-exports | +| `packages/core/src/application/use-cases/auth/sign-in.use-case.ts` | Sign in logic | +| `packages/core/src/application/use-cases/auth/sign-up.use-case.ts` | Sign up logic | +| `packages/core/src/application/use-cases/auth/sign-out.use-case.ts` | Sign out logic | +| `packages/core/src/application/use-cases/content/create-article.use-case.ts` | Create article | +| `packages/core/src/application/use-cases/content/get-articles.use-case.ts` | Get articles | + +### Infrastructure Layer (mock implementations) +| File | Responsibility | +|---|---| +| `packages/core/src/infrastructure/repositories/mock-users.repository.ts` | In-memory users | +| `packages/core/src/infrastructure/repositories/mock-articles.repository.ts` | In-memory articles | +| `packages/core/src/infrastructure/services/mock-auth.service.ts` | Mock sessions | +| `packages/core/src/infrastructure/services/mock-telemetry.service.ts` | No-op telemetry | + +### Interface Adapters +| File | Responsibility | +|---|---| +| `packages/core/src/interface-adapters/controllers/auth/sign-in.controller.ts` | Validate + delegate | +| `packages/core/src/interface-adapters/controllers/auth/sign-up.controller.ts` | Validate + delegate | +| `packages/core/src/interface-adapters/controllers/auth/sign-out.controller.ts` | Validate + delegate | +| `packages/core/src/interface-adapters/controllers/content/articles.controller.ts` | Validate + delegate | + +### DI +| File | Responsibility | +|---|---| +| `packages/core/src/di/types.ts` | DI_SYMBOLS + DI_RETURN_TYPES | +| `packages/core/src/di/modules/auth.module.ts` | Binds auth deps | +| `packages/core/src/di/modules/content.module.ts` | Binds content deps | +| `packages/core/src/di/container.ts` | InversifyJS container | + +### Config + Tests +| File | Responsibility | +|---|---| +| `packages/core/src/config.ts` | Constants (SESSION_COOKIE, etc.) | +| `packages/core/vitest.config.ts` | Vitest config with path aliases | +| `packages/core/src/index.ts` | Public API re-exports | + +### Test files (co-located pattern from reference repo) +| File | Responsibility | +|---|---| +| `packages/core/tests/unit/use-cases/auth/sign-in.use-case.test.ts` | Sign in tests | +| `packages/core/tests/unit/use-cases/auth/sign-up.use-case.test.ts` | Sign up tests | +| `packages/core/tests/unit/use-cases/auth/sign-out.use-case.test.ts` | Sign out tests | +| `packages/core/tests/unit/use-cases/content/create-article.use-case.test.ts` | Create article tests | +| `packages/core/tests/unit/use-cases/content/get-articles.use-case.test.ts` | Get articles tests | +| `packages/core/tests/unit/controllers/auth/sign-in.controller.test.ts` | Sign in controller tests | +| `packages/core/tests/unit/controllers/auth/sign-up.controller.test.ts` | Sign up controller tests | +| `packages/core/tests/unit/controllers/auth/sign-out.controller.test.ts` | Sign out controller tests | +| `packages/core/tests/unit/controllers/content/articles.controller.test.ts` | Articles controller tests | + +--- + +### Task 1: Install dependencies + +**Files:** +- Modify: `packages/core/package.json` +- Modify: `packages/core/tsconfig.json` + +- [ ] **Step 1: Add dependencies to packages/core/package.json** + +```json +{ + "name": "@repo/core", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "inversify": "^6.2.0", + "reflect-metadata": "^0.2.2", + "zod": "^3.24.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "vitest": "^3.1.0" + } +} +``` + +- [ ] **Step 2: Update tsconfig.json to include reflect-metadata** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "types": ["reflect-metadata"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Run pnpm install from repo root** + +Run: `pnpm install` +Expected: Installs inversify, reflect-metadata, zod, vitest. No errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/package.json packages/core/tsconfig.json pnpm-lock.yaml +git commit -m "feat(core): add dependencies (inversify, reflect-metadata, zod, vitest)" +``` + +--- + +### Task 2: Vitest config + constants + +**Files:** +- Create: `packages/core/vitest.config.ts` +- Create: `packages/core/src/config.ts` + +- [ ] **Step 1: Create vitest.config.ts** + +```typescript +import { defineConfig } from "vitest/config"; +import { fileURLToPath, URL } from "node:url"; + +export default defineConfig({ + test: { + globals: true, + coverage: { + provider: "v8", + reportsDirectory: "./tests/coverage", + }, + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +}); +``` + +- [ ] **Step 2: Create src/config.ts** + +```typescript +export const SESSION_COOKIE = "session"; +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/vitest.config.ts packages/core/src/config.ts +git commit -m "feat(core): add vitest config and constants" +``` + +--- + +### Task 3: Entities — models + +**Files:** +- Create: `packages/core/src/entities/models/user.ts` +- Create: `packages/core/src/entities/models/article.ts` +- Create: `packages/core/src/entities/models/session.ts` +- Create: `packages/core/src/entities/models/cookie.ts` +- Create: `packages/core/src/entities/models/index.ts` + +- [ ] **Step 1: Create user.ts** + +```typescript +import { z } from "zod"; + +export const userSchema = z.object({ + id: z.string(), + username: z.string().min(3).max(31), + passwordHash: z.string().min(6).max(255), +}); + +export type User = z.infer; +``` + +- [ ] **Step 2: Create article.ts** + +```typescript +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.string(), + status: articleStatusSchema.default("draft"), + authorId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type Article = z.infer; +export type ArticleStatus = z.infer; +``` + +- [ ] **Step 3: Create session.ts** + +```typescript +import { z } from "zod"; + +export const sessionSchema = z.object({ + id: z.string(), + userId: z.string(), + expiresAt: z.date(), +}); + +export type Session = z.infer; +``` + +- [ ] **Step 4: Create cookie.ts** + +```typescript +type CookieAttributes = { + secure?: boolean; + path?: string; + domain?: string; + sameSite?: "lax" | "strict" | "none"; + httpOnly?: boolean; + maxAge?: number; + expires?: Date; +}; + +export type Cookie = { + name: string; + value: string; + attributes: CookieAttributes; +}; +``` + +- [ ] **Step 5: Create index.ts** + +```typescript +export { userSchema, type User } from "./user.js"; +export { + articleSchema, + articleStatusSchema, + type Article, + type ArticleStatus, +} from "./article.js"; +export { sessionSchema, type Session } from "./session.js"; +export type { Cookie } from "./cookie.js"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/entities/models/ +git commit -m "feat(core): add entity models (user, article, session, cookie)" +``` + +--- + +### Task 4: Entities — errors + +**Files:** +- Create: `packages/core/src/entities/errors/auth.ts` +- Create: `packages/core/src/entities/errors/common.ts` +- Create: `packages/core/src/entities/errors/index.ts` +- Create: `packages/core/src/entities/index.ts` + +- [ ] **Step 1: Create auth.ts** + +```typescript +export class AuthenticationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export class UnauthenticatedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export class UnauthorizedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} +``` + +- [ ] **Step 2: Create common.ts** + +```typescript +export class NotFoundError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} +``` + +- [ ] **Step 3: Create errors/index.ts** + +```typescript +export { + AuthenticationError, + UnauthenticatedError, + UnauthorizedError, +} from "./auth.js"; +export { NotFoundError, InputParseError } from "./common.js"; +``` + +- [ ] **Step 4: Create entities/index.ts** + +```typescript +export * from "./models/index.js"; +export * from "./errors/index.js"; +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/entities/ +git commit -m "feat(core): add entity errors (auth, common)" +``` + +--- + +### Task 5: Application — repository and service interfaces + +**Files:** +- Create: `packages/core/src/application/repositories/users.repository.interface.ts` +- Create: `packages/core/src/application/repositories/articles.repository.interface.ts` +- Create: `packages/core/src/application/repositories/index.ts` +- Create: `packages/core/src/application/services/auth.service.interface.ts` +- Create: `packages/core/src/application/services/telemetry.service.interface.ts` +- Create: `packages/core/src/application/services/index.ts` + +- [ ] **Step 1: Create users.repository.interface.ts** + +```typescript +import type { User } from "@/entities/models/user.js"; + +export interface IUsersRepository { + getUser(id: string): Promise; + getUserByUsername(username: string): Promise; + createUser(input: User): Promise; +} +``` + +- [ ] **Step 2: Create articles.repository.interface.ts** + +```typescript +import type { Article } from "@/entities/models/article.js"; + +export interface IArticlesRepository { + getArticle(id: string): Promise
; + getArticles(options?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; + }): Promise; + createArticle(input: Article): Promise
; + updateArticle(id: string, input: Partial
): Promise
; +} +``` + +- [ ] **Step 3: Create repositories/index.ts** + +```typescript +export type { IUsersRepository } from "./users.repository.interface.js"; +export type { IArticlesRepository } from "./articles.repository.interface.js"; +``` + +- [ ] **Step 4: Create auth.service.interface.ts** + +```typescript +import type { Cookie } from "@/entities/models/cookie.js"; +import type { Session } from "@/entities/models/session.js"; +import type { User } from "@/entities/models/user.js"; + +export interface IAuthenticationService { + generateUserId(): string; + hashPassword(password: string): Promise; + verifyPassword(hash: string, password: string): Promise; + validateSession( + sessionId: string + ): Promise<{ user: User; session: Session }>; + createSession(user: User): Promise<{ session: Session; cookie: Cookie }>; + invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>; +} +``` + +- [ ] **Step 5: Create telemetry.service.interface.ts** + +```typescript +export interface ITelemetryService { + startSpan(name: string, fn: () => T | Promise): Promise; +} +``` + +- [ ] **Step 6: Create services/index.ts** + +```typescript +export type { IAuthenticationService } from "./auth.service.interface.js"; +export type { ITelemetryService } from "./telemetry.service.interface.js"; +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/application/ +git commit -m "feat(core): add application interfaces (repositories + services)" +``` + +--- + +### Task 6: DI — types, modules, container + +**Files:** +- Create: `packages/core/src/di/types.ts` +- Create: `packages/core/src/di/modules/auth.module.ts` +- Create: `packages/core/src/di/modules/content.module.ts` +- Create: `packages/core/src/di/container.ts` + +- [ ] **Step 1: Create di/types.ts** + +```typescript +import type { IAuthenticationService } from "@/application/services/auth.service.interface.js"; +import type { ITelemetryService } from "@/application/services/telemetry.service.interface.js"; +import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js"; +import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js"; + +export const DI_SYMBOLS = { + IAuthenticationService: Symbol.for("IAuthenticationService"), + ITelemetryService: Symbol.for("ITelemetryService"), + IUsersRepository: Symbol.for("IUsersRepository"), + IArticlesRepository: Symbol.for("IArticlesRepository"), +}; + +export interface DI_RETURN_TYPES { + IAuthenticationService: IAuthenticationService; + ITelemetryService: ITelemetryService; + IUsersRepository: IUsersRepository; + IArticlesRepository: IArticlesRepository; +} +``` + +- [ ] **Step 2: Create di/modules/auth.module.ts** + +```typescript +import { ContainerModule, interfaces } from "inversify"; + +import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js"; +import type { IAuthenticationService } from "@/application/services/auth.service.interface.js"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository.js"; +import { MockAuthenticationService } from "@/infrastructure/services/mock-auth.service.js"; +import { DI_SYMBOLS } from "../types.js"; + +const initializeModule = (bind: interfaces.Bind) => { + bind(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository); + bind(DI_SYMBOLS.IAuthenticationService).to( + MockAuthenticationService + ); +}; + +export const AuthModule = new ContainerModule(initializeModule); +``` + +- [ ] **Step 3: Create di/modules/content.module.ts** + +```typescript +import { ContainerModule, interfaces } from "inversify"; + +import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository.js"; +import { DI_SYMBOLS } from "../types.js"; + +const initializeModule = (bind: interfaces.Bind) => { + bind(DI_SYMBOLS.IArticlesRepository).to( + MockArticlesRepository + ); +}; + +export const ContentModule = new ContainerModule(initializeModule); +``` + +- [ ] **Step 4: Create di/container.ts** + +```typescript +import "reflect-metadata"; +import { Container } from "inversify"; + +import { AuthModule } from "./modules/auth.module.js"; +import { ContentModule } from "./modules/content.module.js"; +import { DI_RETURN_TYPES, DI_SYMBOLS } from "./types.js"; + +const ApplicationContainer = new Container({ + defaultScope: "Singleton", +}); + +export const initializeContainer = () => { + ApplicationContainer.load(AuthModule); + ApplicationContainer.load(ContentModule); +}; + +export const destroyContainer = () => { + ApplicationContainer.unload(AuthModule); + ApplicationContainer.unload(ContentModule); +}; + +if (process.env.NODE_ENV !== "test") { + initializeContainer(); +} + +export function getInjection( + symbol: K +): DI_RETURN_TYPES[K] { + return ApplicationContainer.get(DI_SYMBOLS[symbol]); +} + +export { ApplicationContainer }; +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/di/ +git commit -m "feat(core): add InversifyJS DI container with auth and content modules" +``` + +--- + +### Task 7: Infrastructure — mock implementations + +**Files:** +- Create: `packages/core/src/infrastructure/repositories/mock-users.repository.ts` +- Create: `packages/core/src/infrastructure/repositories/mock-articles.repository.ts` +- Create: `packages/core/src/infrastructure/services/mock-auth.service.ts` +- Create: `packages/core/src/infrastructure/services/mock-telemetry.service.ts` + +- [ ] **Step 1: Create mock-users.repository.ts** + +```typescript +import { injectable } from "inversify"; + +import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js"; +import type { User } from "@/entities/models/user.js"; + +@injectable() +export class MockUsersRepository implements IUsersRepository { + private _users: User[] = [ + { id: "1", username: "alice", passwordHash: "hashed_password_alice" }, + { id: "2", username: "bob", passwordHash: "hashed_password_bob" }, + ]; + + async getUser(id: string): Promise { + return this._users.find((u) => u.id === id); + } + + async getUserByUsername(username: string): Promise { + return this._users.find((u) => u.username === username); + } + + async createUser(input: User): Promise { + this._users.push(input); + return input; + } +} +``` + +- [ ] **Step 2: Create mock-articles.repository.ts** + +```typescript +import { injectable } from "inversify"; + +import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js"; +import type { Article } from "@/entities/models/article.js"; + +@injectable() +export class MockArticlesRepository implements IArticlesRepository { + private _articles: Article[] = []; + + async getArticle(id: string): Promise
{ + return this._articles.find((a) => a.id === id); + } + + 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; + this._articles[index] = { ...this._articles[index]!, ...input }; + return this._articles[index]; + } +} +``` + +- [ ] **Step 3: Create mock-auth.service.ts** + +```typescript +import { inject, injectable } from "inversify"; + +import type { IAuthenticationService } from "@/application/services/auth.service.interface.js"; +import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js"; +import { UnauthenticatedError } from "@/entities/errors/auth.js"; +import { sessionSchema, type Session } from "@/entities/models/session.js"; +import type { Cookie } from "@/entities/models/cookie.js"; +import type { User } from "@/entities/models/user.js"; +import { DI_SYMBOLS } from "@/di/types.js"; +import { SESSION_COOKIE } from "@/config.js"; + +@injectable() +export class MockAuthenticationService implements IAuthenticationService { + private _sessions: Record = {}; + + constructor( + @inject(DI_SYMBOLS.IUsersRepository) + private _usersRepository: IUsersRepository + ) {} + + generateUserId(): string { + return (Math.random() + 1).toString(36).substring(7); + } + + async hashPassword(password: string): Promise { + return `hashed_${password}`; + } + + async verifyPassword(hash: string, password: string): Promise { + return hash === `hashed_${password}`; + } + + async validateSession( + sessionId: string + ): Promise<{ user: User; session: Session }> { + const result = this._sessions[sessionId]; + if (!result) { + throw new UnauthenticatedError("Unauthenticated"); + } + const user = await this._usersRepository.getUser(result.user.id); + if (!user) { + throw new UnauthenticatedError("Unauthenticated"); + } + return { user, session: result.session }; + } + + async createSession( + user: User + ): Promise<{ session: Session; cookie: Cookie }> { + const session = sessionSchema.parse({ + id: "session_" + user.id, + userId: user.id, + expiresAt: new Date(Date.now() + 86400000 * 7), + }); + const cookie: Cookie = { + name: SESSION_COOKIE, + value: session.id, + attributes: {}, + }; + this._sessions[session.id] = { session, user }; + return { session, cookie }; + } + + async invalidateSession( + sessionId: string + ): Promise<{ blankCookie: Cookie }> { + delete this._sessions[sessionId]; + return { + blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} }, + }; + } +} +``` + +- [ ] **Step 4: Create mock-telemetry.service.ts** + +```typescript +import { injectable } from "inversify"; + +import type { ITelemetryService } from "@/application/services/telemetry.service.interface.js"; + +@injectable() +export class MockTelemetryService implements ITelemetryService { + async startSpan(_name: string, fn: () => T | Promise): Promise { + return fn(); + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/infrastructure/ +git commit -m "feat(core): add mock implementations (users, articles, auth, telemetry)" +``` + +--- + +### Task 8: Auth use cases + tests (TDD) + +**Files:** +- Create: `packages/core/src/application/use-cases/auth/sign-in.use-case.ts` +- Create: `packages/core/src/application/use-cases/auth/sign-up.use-case.ts` +- Create: `packages/core/src/application/use-cases/auth/sign-out.use-case.ts` +- Create: `packages/core/tests/unit/use-cases/auth/sign-in.use-case.test.ts` +- Create: `packages/core/tests/unit/use-cases/auth/sign-up.use-case.test.ts` +- Create: `packages/core/tests/unit/use-cases/auth/sign-out.use-case.test.ts` + +- [ ] **Step 1: Write sign-in test** + +```typescript +// packages/core/tests/unit/use-cases/auth/sign-in.use-case.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case.js"; +import { AuthenticationError } from "@/entities/errors/auth.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("signInUseCase", () => { + it("returns session and cookie for valid credentials", async () => { + const result = await signInUseCase({ + username: "alice", + password: "password_alice", + }); + expect(result).toHaveProperty("session"); + expect(result).toHaveProperty("cookie"); + expect(result.session.userId).toBe("1"); + }); + + it("throws AuthenticationError for non-existing user", async () => { + await expect( + signInUseCase({ username: "non-existing", password: "any" }) + ).rejects.toBeInstanceOf(AuthenticationError); + }); + + it("throws AuthenticationError for wrong password", async () => { + await expect( + signInUseCase({ username: "alice", password: "wrong" }) + ).rejects.toBeInstanceOf(AuthenticationError); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/core && pnpm vitest run tests/unit/use-cases/auth/sign-in.use-case.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement sign-in use case** + +```typescript +// packages/core/src/application/use-cases/auth/sign-in.use-case.ts +import { AuthenticationError } from "@/entities/errors/auth.js"; +import type { Cookie } from "@/entities/models/cookie.js"; +import type { Session } from "@/entities/models/session.js"; +import { getInjection } from "@/di/container.js"; + +export async function signInUseCase(input: { + username: string; + password: string; +}): Promise<{ session: Session; cookie: Cookie }> { + const usersRepository = getInjection("IUsersRepository"); + const authService = getInjection("IAuthenticationService"); + + const existingUser = await usersRepository.getUserByUsername(input.username); + if (!existingUser) { + throw new AuthenticationError("User does not exist"); + } + + const validPassword = await authService.verifyPassword( + existingUser.passwordHash, + input.password + ); + if (!validPassword) { + throw new AuthenticationError("Incorrect username or password"); + } + + return await authService.createSession(existingUser); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/core && pnpm vitest run tests/unit/use-cases/auth/sign-in.use-case.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Write sign-up test** + +```typescript +// packages/core/tests/unit/use-cases/auth/sign-up.use-case.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case.js"; +import { AuthenticationError } from "@/entities/errors/auth.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("signUpUseCase", () => { + it("creates user and returns session, cookie, and user info", async () => { + const result = await signUpUseCase({ + username: "newuser", + password: "securepassword", + }); + expect(result).toHaveProperty("session"); + expect(result).toHaveProperty("cookie"); + expect(result).toHaveProperty("user"); + expect(result.user.username).toBe("newuser"); + }); + + it("throws AuthenticationError if username is taken", async () => { + await expect( + signUpUseCase({ username: "alice", password: "anypassword" }) + ).rejects.toBeInstanceOf(AuthenticationError); + }); +}); +``` + +- [ ] **Step 6: Implement sign-up use case** + +```typescript +// packages/core/src/application/use-cases/auth/sign-up.use-case.ts +import { AuthenticationError } from "@/entities/errors/auth.js"; +import type { Cookie } from "@/entities/models/cookie.js"; +import type { Session } from "@/entities/models/session.js"; +import type { User } from "@/entities/models/user.js"; +import { getInjection } from "@/di/container.js"; + +export async function signUpUseCase(input: { + username: string; + password: string; +}): Promise<{ + session: Session; + cookie: Cookie; + user: Pick; +}> { + const usersRepository = getInjection("IUsersRepository"); + const authService = getInjection("IAuthenticationService"); + + const existingUser = await usersRepository.getUserByUsername(input.username); + if (existingUser) { + throw new AuthenticationError("Username taken"); + } + + const passwordHash = await authService.hashPassword(input.password); + const userId = authService.generateUserId(); + + const newUser = await usersRepository.createUser({ + id: userId, + username: input.username, + passwordHash, + }); + + const { cookie, session } = await authService.createSession(newUser); + + return { + cookie, + session, + user: { id: newUser.id, username: newUser.username }, + }; +} +``` + +- [ ] **Step 7: Run sign-up test** + +Run: `cd packages/core && pnpm vitest run tests/unit/use-cases/auth/sign-up.use-case.test.ts` +Expected: PASS (2 tests) + +- [ ] **Step 8: Write sign-out test** + +```typescript +// packages/core/tests/unit/use-cases/auth/sign-out.use-case.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("signOutUseCase", () => { + it("returns a blank cookie", async () => { + const result = await signOutUseCase("some-session-id"); + expect(result).toHaveProperty("blankCookie"); + expect(result.blankCookie.value).toBe(""); + }); +}); +``` + +- [ ] **Step 9: Implement sign-out use case** + +```typescript +// packages/core/src/application/use-cases/auth/sign-out.use-case.ts +import type { Cookie } from "@/entities/models/cookie.js"; +import { getInjection } from "@/di/container.js"; + +export async function signOutUseCase( + sessionId: string +): Promise<{ blankCookie: Cookie }> { + const authService = getInjection("IAuthenticationService"); + return await authService.invalidateSession(sessionId); +} +``` + +- [ ] **Step 10: Run all auth tests** + +Run: `cd packages/core && pnpm vitest run tests/unit/use-cases/auth/` +Expected: PASS (6 tests total) + +- [ ] **Step 11: Commit** + +```bash +git add packages/core/src/application/use-cases/auth/ packages/core/tests/ +git commit -m "feat(core): add auth use cases with tests (sign-in, sign-up, sign-out)" +``` + +--- + +### Task 9: Content use cases + tests (TDD) + +**Files:** +- Create: `packages/core/src/application/use-cases/content/create-article.use-case.ts` +- Create: `packages/core/src/application/use-cases/content/get-articles.use-case.ts` +- Create: `packages/core/tests/unit/use-cases/content/create-article.use-case.test.ts` +- Create: `packages/core/tests/unit/use-cases/content/get-articles.use-case.test.ts` + +- [ ] **Step 1: Write create-article test** + +```typescript +// packages/core/tests/unit/use-cases/content/create-article.use-case.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("createArticleUseCase", () => { + it("creates an article with generated slug and draft status", async () => { + const result = await createArticleUseCase({ + title: "My First Article", + content: "Hello world", + authorId: "1", + }); + expect(result.title).toBe("My First Article"); + expect(result.slug).toBe("my-first-article"); + expect(result.status).toBe("draft"); + expect(result.authorId).toBe("1"); + expect(result.id).toBeDefined(); + }); + + it("uses provided slug if given", async () => { + const result = await createArticleUseCase({ + title: "Another Article", + content: "Content here", + authorId: "1", + slug: "custom-slug", + }); + expect(result.slug).toBe("custom-slug"); + }); +}); +``` + +- [ ] **Step 2: Implement create-article use case** + +```typescript +// packages/core/src/application/use-cases/content/create-article.use-case.ts +import type { Article } from "@/entities/models/article.js"; +import { getInjection } from "@/di/container.js"; + +function generateSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export async function createArticleUseCase(input: { + title: string; + content: string; + authorId: string; + slug?: string; +}): Promise
{ + const articlesRepository = getInjection("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 await articlesRepository.createArticle(article); +} +``` + +- [ ] **Step 3: Run create-article test** + +Run: `cd packages/core && pnpm vitest run tests/unit/use-cases/content/create-article.use-case.test.ts` +Expected: PASS (2 tests) + +- [ ] **Step 4: Write get-articles test** + +```typescript +// packages/core/tests/unit/use-cases/content/get-articles.use-case.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case.js"; +import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("getArticlesUseCase", () => { + it("returns empty array when no articles exist", async () => { + const result = await getArticlesUseCase(); + expect(result).toEqual([]); + }); + + it("returns created articles", async () => { + await createArticleUseCase({ + title: "Article One", + content: "Content one", + authorId: "1", + }); + await createArticleUseCase({ + title: "Article Two", + content: "Content two", + authorId: "1", + }); + + const result = await getArticlesUseCase(); + expect(result).toHaveLength(2); + }); + + it("filters by status", async () => { + await createArticleUseCase({ + title: "Draft Article", + content: "Draft", + authorId: "1", + }); + + const result = await getArticlesUseCase({ status: "published" }); + expect(result).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 5: Implement get-articles use case** + +```typescript +// packages/core/src/application/use-cases/content/get-articles.use-case.ts +import type { Article } from "@/entities/models/article.js"; +import { getInjection } from "@/di/container.js"; + +export async function getArticlesUseCase(options?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; +}): Promise { + const articlesRepository = getInjection("IArticlesRepository"); + return await articlesRepository.getArticles(options); +} +``` + +- [ ] **Step 6: Run all content tests** + +Run: `cd packages/core && pnpm vitest run tests/unit/use-cases/content/` +Expected: PASS (5 tests total) + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/application/use-cases/content/ packages/core/tests/unit/use-cases/content/ +git commit -m "feat(core): add content use cases with tests (create-article, get-articles)" +``` + +--- + +### Task 10: Auth controllers + tests (TDD) + +**Files:** +- Create: `packages/core/src/interface-adapters/controllers/auth/sign-in.controller.ts` +- Create: `packages/core/src/interface-adapters/controllers/auth/sign-up.controller.ts` +- Create: `packages/core/src/interface-adapters/controllers/auth/sign-out.controller.ts` +- Create: `packages/core/tests/unit/controllers/auth/sign-in.controller.test.ts` +- Create: `packages/core/tests/unit/controllers/auth/sign-up.controller.test.ts` +- Create: `packages/core/tests/unit/controllers/auth/sign-out.controller.test.ts` + +- [ ] **Step 1: Write sign-in controller test** + +```typescript +// packages/core/tests/unit/controllers/auth/sign-in.controller.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { signInController } from "@/interface-adapters/controllers/auth/sign-in.controller.js"; +import { InputParseError } from "@/entities/errors/common.js"; +import { AuthenticationError } from "@/entities/errors/auth.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("signInController", () => { + it("returns cookie for valid input", async () => { + const cookie = await signInController({ + username: "alice", + password: "password_alice", + }); + expect(cookie).toHaveProperty("name"); + expect(cookie).toHaveProperty("value"); + }); + + it("throws InputParseError for invalid input", async () => { + await expect( + signInController({ username: "ab", password: "short" }) + ).rejects.toBeInstanceOf(InputParseError); + }); + + it("throws AuthenticationError for wrong credentials", async () => { + await expect( + signInController({ username: "alice", password: "wrongpassword" }) + ).rejects.toBeInstanceOf(AuthenticationError); + }); +}); +``` + +- [ ] **Step 2: Implement sign-in controller** + +```typescript +// packages/core/src/interface-adapters/controllers/auth/sign-in.controller.ts +import { z } from "zod"; + +import { InputParseError } from "@/entities/errors/common.js"; +import type { Cookie } from "@/entities/models/cookie.js"; +import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case.js"; + +const inputSchema = z.object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), +}); + +export async function signInController( + input: Partial> +): Promise { + const { data, error: inputParseError } = inputSchema.safeParse(input); + + if (inputParseError) { + throw new InputParseError("Invalid data", { cause: inputParseError }); + } + + const { cookie } = await signInUseCase(data); + return cookie; +} +``` + +- [ ] **Step 3: Run sign-in controller test** + +Run: `cd packages/core && pnpm vitest run tests/unit/controllers/auth/sign-in.controller.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 4: Write sign-up controller test** + +```typescript +// packages/core/tests/unit/controllers/auth/sign-up.controller.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { signUpController } from "@/interface-adapters/controllers/auth/sign-up.controller.js"; +import { InputParseError } from "@/entities/errors/common.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("signUpController", () => { + it("returns session, cookie, and user for valid input", async () => { + const result = await signUpController({ + username: "newuser", + password: "securepassword", + confirmPassword: "securepassword", + }); + expect(result).toHaveProperty("session"); + expect(result).toHaveProperty("cookie"); + expect(result).toHaveProperty("user"); + }); + + it("throws InputParseError when passwords don't match", async () => { + await expect( + signUpController({ + username: "newuser", + password: "password1", + confirmPassword: "password2", + }) + ).rejects.toBeInstanceOf(InputParseError); + }); + + it("throws InputParseError for missing fields", async () => { + await expect(signUpController({})).rejects.toBeInstanceOf(InputParseError); + }); +}); +``` + +- [ ] **Step 5: Implement sign-up controller** + +```typescript +// packages/core/src/interface-adapters/controllers/auth/sign-up.controller.ts +import { z } from "zod"; + +import { InputParseError } from "@/entities/errors/common.js"; +import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case.js"; + +const inputSchema = z + .object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), + confirmPassword: z.string().min(6).max(255), + }) + .superRefine(({ password, confirmPassword }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + code: "custom", + message: "The passwords did not match", + path: ["password"], + }); + ctx.addIssue({ + code: "custom", + message: "The passwords did not match", + path: ["confirmPassword"], + }); + } + }); + +export async function signUpController( + input: Partial> +): Promise> { + const { data, error: inputParseError } = inputSchema.safeParse(input); + + if (inputParseError) { + throw new InputParseError("Invalid data", { cause: inputParseError }); + } + + return await signUpUseCase(data); +} +``` + +- [ ] **Step 6: Run sign-up controller test** + +Run: `cd packages/core && pnpm vitest run tests/unit/controllers/auth/sign-up.controller.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 7: Write sign-out controller test** + +```typescript +// packages/core/tests/unit/controllers/auth/sign-out.controller.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { signOutController } from "@/interface-adapters/controllers/auth/sign-out.controller.js"; +import { InputParseError } from "@/entities/errors/common.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("signOutController", () => { + it("returns blank cookie for valid session", async () => { + const cookie = await signOutController("some-session-id"); + expect(cookie.value).toBe(""); + }); + + it("throws InputParseError when no session ID provided", async () => { + await expect(signOutController(undefined)).rejects.toBeInstanceOf( + InputParseError + ); + }); +}); +``` + +- [ ] **Step 8: Implement sign-out controller** + +```typescript +// packages/core/src/interface-adapters/controllers/auth/sign-out.controller.ts +import { InputParseError } from "@/entities/errors/common.js"; +import type { Cookie } from "@/entities/models/cookie.js"; +import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case.js"; + +export async function signOutController( + sessionId: string | undefined +): Promise { + if (!sessionId) { + throw new InputParseError("Must provide a session ID"); + } + + const { blankCookie } = await signOutUseCase(sessionId); + return blankCookie; +} +``` + +- [ ] **Step 9: Run all auth controller tests** + +Run: `cd packages/core && pnpm vitest run tests/unit/controllers/auth/` +Expected: PASS (8 tests total) + +- [ ] **Step 10: Commit** + +```bash +git add packages/core/src/interface-adapters/controllers/auth/ packages/core/tests/unit/controllers/auth/ +git commit -m "feat(core): add auth controllers with tests (sign-in, sign-up, sign-out)" +``` + +--- + +### Task 11: Content controller + tests (TDD) + +**Files:** +- Create: `packages/core/src/interface-adapters/controllers/content/articles.controller.ts` +- Create: `packages/core/tests/unit/controllers/content/articles.controller.test.ts` + +- [ ] **Step 1: Write articles controller test** + +```typescript +// packages/core/tests/unit/controllers/content/articles.controller.test.ts +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + destroyContainer, + initializeContainer, +} from "@/di/container.js"; +import { + createArticleController, + getArticlesController, +} from "@/interface-adapters/controllers/content/articles.controller.js"; +import { InputParseError } from "@/entities/errors/common.js"; + +beforeEach(() => { + initializeContainer(); +}); + +afterEach(() => { + destroyContainer(); +}); + +describe("createArticleController", () => { + it("creates an article with valid input", async () => { + const result = await createArticleController({ + title: "Test Article", + content: "Some content", + authorId: "1", + }); + expect(result.title).toBe("Test Article"); + expect(result.slug).toBe("test-article"); + }); + + it("throws InputParseError for missing title", async () => { + await expect( + createArticleController({ content: "content", authorId: "1" } as any) + ).rejects.toBeInstanceOf(InputParseError); + }); +}); + +describe("getArticlesController", () => { + it("returns articles", async () => { + await createArticleController({ + title: "Article", + content: "Content", + authorId: "1", + }); + const result = await getArticlesController({}); + expect(result).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Implement articles controller** + +```typescript +// packages/core/src/interface-adapters/controllers/content/articles.controller.ts +import { z } from "zod"; + +import { InputParseError } from "@/entities/errors/common.js"; +import type { Article } from "@/entities/models/article.js"; +import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case.js"; +import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case.js"; + +const createInputSchema = z.object({ + title: z.string().min(1).max(255), + content: z.string(), + 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(), +}); + +export async function createArticleController( + input: Partial> +): Promise
{ + const { data, error: inputParseError } = createInputSchema.safeParse(input); + + if (inputParseError) { + throw new InputParseError("Invalid data", { cause: inputParseError }); + } + + return await createArticleUseCase(data); +} + +export async function getArticlesController( + input: Partial> +): Promise { + const { data, error: inputParseError } = getInputSchema.safeParse(input); + + if (inputParseError) { + throw new InputParseError("Invalid data", { cause: inputParseError }); + } + + return await getArticlesUseCase(data); +} +``` + +- [ ] **Step 3: Run content controller tests** + +Run: `cd packages/core && pnpm vitest run tests/unit/controllers/content/` +Expected: PASS (3 tests) + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/interface-adapters/controllers/content/ packages/core/tests/unit/controllers/content/ +git commit -m "feat(core): add content controller with tests (articles CRUD)" +``` + +--- + +### Task 12: Update index.ts + run all tests + +**Files:** +- Modify: `packages/core/src/index.ts` + +- [ ] **Step 1: Update src/index.ts with public API** + +```typescript +// @repo/core — Clean Architecture core package +export * from "./entities/index.js"; +export * from "./application/repositories/index.js"; +export * from "./application/services/index.js"; +export { signInUseCase } from "./application/use-cases/auth/sign-in.use-case.js"; +export { signUpUseCase } from "./application/use-cases/auth/sign-up.use-case.js"; +export { signOutUseCase } from "./application/use-cases/auth/sign-out.use-case.js"; +export { createArticleUseCase } from "./application/use-cases/content/create-article.use-case.js"; +export { getArticlesUseCase } from "./application/use-cases/content/get-articles.use-case.js"; +export { signInController } from "./interface-adapters/controllers/auth/sign-in.controller.js"; +export { signUpController } from "./interface-adapters/controllers/auth/sign-up.controller.js"; +export { signOutController } from "./interface-adapters/controllers/auth/sign-out.controller.js"; +export { + createArticleController, + getArticlesController, +} from "./interface-adapters/controllers/content/articles.controller.js"; +export { + getInjection, + initializeContainer, + destroyContainer, +} from "./di/container.js"; +export { DI_SYMBOLS } from "./di/types.js"; +``` + +- [ ] **Step 2: Run ALL tests** + +Run: `cd packages/core && pnpm vitest run` +Expected: PASS — all tests (approximately 19 tests across 9 test files) + +- [ ] **Step 3: Run turbo build from root** + +Run: `pnpm build` +Expected: All workspaces build successfully + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/index.ts +git commit -m "feat(core): update public API exports" +```