12 tasks covering: dependencies, vitest config, entities (models + errors), application interfaces, DI container (InversifyJS), mock infrastructure, auth use cases + tests (TDD), content use cases + tests (TDD), auth controllers + tests, content controllers + tests, public API exports.
1640 lines
48 KiB
Markdown
1640 lines
48 KiB
Markdown
# 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<typeof userSchema>;
|
|
```
|
|
|
|
- [ ] **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<typeof articleSchema>;
|
|
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
|
|
```
|
|
|
|
- [ ] **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<typeof sessionSchema>;
|
|
```
|
|
|
|
- [ ] **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<User | undefined>;
|
|
getUserByUsername(username: string): Promise<User | undefined>;
|
|
createUser(input: User): Promise<User>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create articles.repository.interface.ts**
|
|
|
|
```typescript
|
|
import type { Article } from "@/entities/models/article.js";
|
|
|
|
export interface IArticlesRepository {
|
|
getArticle(id: string): Promise<Article | undefined>;
|
|
getArticles(options?: {
|
|
status?: string;
|
|
authorId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Article[]>;
|
|
createArticle(input: Article): Promise<Article>;
|
|
updateArticle(id: string, input: Partial<Article>): Promise<Article | undefined>;
|
|
}
|
|
```
|
|
|
|
- [ ] **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<string>;
|
|
verifyPassword(hash: string, password: string): Promise<boolean>;
|
|
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<T>(name: string, fn: () => T | Promise<T>): Promise<T>;
|
|
}
|
|
```
|
|
|
|
- [ ] **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<IUsersRepository>(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository);
|
|
bind<IAuthenticationService>(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<IArticlesRepository>(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<K extends keyof typeof DI_SYMBOLS>(
|
|
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<User | undefined> {
|
|
return this._users.find((u) => u.id === id);
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
return this._users.find((u) => u.username === username);
|
|
}
|
|
|
|
async createUser(input: User): Promise<User> {
|
|
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<Article | undefined> {
|
|
return this._articles.find((a) => a.id === id);
|
|
}
|
|
|
|
async getArticles(options?: {
|
|
status?: string;
|
|
authorId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<Article[]> {
|
|
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<Article> {
|
|
this._articles.push(input);
|
|
return input;
|
|
}
|
|
|
|
async updateArticle(
|
|
id: string,
|
|
input: Partial<Article>
|
|
): Promise<Article | undefined> {
|
|
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<string, { session: Session; user: User }> = {};
|
|
|
|
constructor(
|
|
@inject(DI_SYMBOLS.IUsersRepository)
|
|
private _usersRepository: IUsersRepository
|
|
) {}
|
|
|
|
generateUserId(): string {
|
|
return (Math.random() + 1).toString(36).substring(7);
|
|
}
|
|
|
|
async hashPassword(password: string): Promise<string> {
|
|
return `hashed_${password}`;
|
|
}
|
|
|
|
async verifyPassword(hash: string, password: string): Promise<boolean> {
|
|
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<T>(_name: string, fn: () => T | Promise<T>): Promise<T> {
|
|
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<User, "id" | "username">;
|
|
}> {
|
|
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<Article> {
|
|
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<Article[]> {
|
|
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<z.infer<typeof inputSchema>>
|
|
): Promise<Cookie> {
|
|
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<z.infer<typeof inputSchema>>
|
|
): Promise<ReturnType<typeof signUpUseCase>> {
|
|
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<Cookie> {
|
|
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<z.infer<typeof createInputSchema>>
|
|
): Promise<Article> {
|
|
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<z.infer<typeof getInputSchema>>
|
|
): Promise<Article[]> {
|
|
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"
|
|
```
|