# 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" ```