17 tasks: full auth feature canonical migration (entities, services, use-cases, DI with constructor-injected service, controllers, users collection, tRPC router); skeleton media feature (collection only); restore blog's author→users relation and featuredImage→media upload. Bakes in all Plan-2 lessons (vitest alias, no rootDir, relative imports in src, no @repo/core-cms in feature deps). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1882 lines
59 KiB
Markdown
1882 lines
59 KiB
Markdown
# Vertical Refactor — Plan 3: Auth + Media + Restore Blog Cross-Feature Relations
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Migrate the existing `auth` domain (Users collection, sign-in/up/out flows) into a new `@repo/auth` feature package, migrate the Media collection into a new `@repo/media` feature package, and restore the cross-feature relationships in `@repo/blog/integrations/cms/collections/articles.ts` (`author` → relationship to users, `featuredImage` → upload to media). Demonstrates that the per-feature DI pattern + composition exception (`core-cms` aggregating multiple feature `/cms` exports) scales beyond one feature.
|
|
|
|
**Architecture:** Two new feature packages following the canonical pattern proven in Plan 2. Auth has multiple use-cases, two services (auth, dropping the unused telemetry), and depends on InversifyJS constructor injection (auth service takes users repo). Media is minimal — collection + barrel only (no use-cases or DI yet; spec addendum v5 "no empty folders").
|
|
|
|
**Tech Stack:** Same as Plan 2.
|
|
|
|
**Plan position:** Plan 3 of 6.
|
|
- Plan 1 ✅ Foundation
|
|
- Plan 2 ✅ Blog feature
|
|
- **Plan 3 (this doc):** Auth + Media + restore blog relations
|
|
- Plan 4: Marketing-pages + Navigation features
|
|
- Plan 5: App + UI integration (core-trpc client/providers, route handlers, example pages)
|
|
- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite
|
|
|
|
**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md`
|
|
|
|
**Lessons from Plan 2 (apply throughout):**
|
|
1. **Vitest config**: each new feature's `vitest.config.ts` MUST include `resolve.alias: { "@": path.resolve(__dirname, "./src") }` — vitest doesn't read tsconfig paths.
|
|
2. **No `rootDir` in tsconfig**: removed in blog because `tests/**/*` is outside `src/`. Same for new features.
|
|
3. **Source files use relative imports** (NOT `@/`). `@/` reserved for test files within the feature's own context. Reason: when a downstream package typechecks and follows imports into the feature, it can't resolve the feature's `@/` alias.
|
|
4. **Payload-backed repos take `SanitizedConfig` via constructor**, NOT `import config from '@repo/core-cms'`. Avoids workspace cycle. Feature `package.json` does NOT depend on `@repo/core-cms`. App boot (Plan 5) supplies the config when binding the prod repo.
|
|
5. **`z.unknown()` in input schemas should be `.optional()`** — Zod treats `unknown` as accepting `undefined` anyway; explicit is more honest.
|
|
|
|
---
|
|
|
|
## Decisions taken in this plan
|
|
|
|
- **Drop `ITelemetryService`** — currently unused in any auth use-case. Spec addendum v5 says don't carry dead code. If telemetry is needed later, add it back via `core-shared/src/telemetry/` (cross-cutting concern, not auth-specific).
|
|
- **Auth domain entity stays `User { id, username, passwordHash }`** — the existing model. The Payload `Users` collection (with `email`, `displayName`, `role`) is a SEPARATE, parallel concept used for Payload admin login. The two are deliberately not wired in this plan; reconciling them is future work. The `author` relationship in blog points at the Payload Users collection (i.e., admin-side users), which is consistent with the existing template.
|
|
- **Media is a "skeleton-only" feature** — collection + barrel only. No entities/use-cases/repos yet. Add when something needs them.
|
|
- **Blog's `author` field becomes `relationship → users`** with `relationTo: "users"` (the Payload collection slug). Blog package doesn't import auth — relationship is by slug, satisfied at Payload runtime when both collections are registered in `core-cms`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Create — new `@repo/auth` package:**
|
|
- `packages/auth/{package.json,tsconfig.json,turbo.json,vitest.config.ts}`
|
|
- `packages/auth/src/index.ts`
|
|
- `packages/auth/src/config.ts` (SESSION_COOKIE constant)
|
|
- `packages/auth/src/entities/user.ts` + `user.test.ts`
|
|
- `packages/auth/src/entities/cookie.ts`
|
|
- `packages/auth/src/entities/session.ts` + `session.test.ts`
|
|
- `packages/auth/src/entities/errors.ts`
|
|
- `packages/auth/src/application/repositories/users-repository.interface.ts`
|
|
- `packages/auth/src/application/services/authentication-service.interface.ts`
|
|
- `packages/auth/src/application/use-cases/sign-in.use-case.ts` + `.test.ts`
|
|
- `packages/auth/src/application/use-cases/sign-up.use-case.ts` + `.test.ts`
|
|
- `packages/auth/src/application/use-cases/sign-out.use-case.ts` + `.test.ts`
|
|
- `packages/auth/src/infrastructure/repositories/mock-users.repository.ts`
|
|
- `packages/auth/src/infrastructure/services/mock-authentication.service.ts`
|
|
- `packages/auth/src/di/symbols.ts`
|
|
- `packages/auth/src/di/module.ts`
|
|
- `packages/auth/src/di/container.ts` + `container.test.ts`
|
|
- `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts` + `.test.ts`
|
|
- `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts` + `.test.ts`
|
|
- `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts` + `.test.ts`
|
|
- `packages/auth/src/integrations/cms/collections/users.ts`
|
|
- `packages/auth/src/integrations/cms/index.ts`
|
|
- `packages/auth/src/integrations/api/router.ts` + `router.test.ts`
|
|
- `packages/auth/src/ui/query.ts`
|
|
- `packages/auth/tests/sign-in-flow.feature.test.ts`
|
|
|
|
**Create — new `@repo/media` package:**
|
|
- `packages/media/{package.json,tsconfig.json,turbo.json}`
|
|
- `packages/media/src/index.ts`
|
|
- `packages/media/src/integrations/cms/collections/media.ts`
|
|
- `packages/media/src/integrations/cms/index.ts`
|
|
|
|
**Modify:**
|
|
- `packages/blog/src/integrations/cms/collections/articles.ts` — restore `author: relationship → users`, add `featuredImage: upload → media`
|
|
- `packages/core-cms/src/payload.config.ts` — register `users`, `media` collections; depend on `@repo/auth` and `@repo/media`
|
|
- `packages/core-cms/package.json` — add `@repo/auth`, `@repo/media`
|
|
- `packages/core-api/src/root.ts` — add `auth: authRouter` to appRouter
|
|
- `packages/core-api/package.json` — add `@repo/auth`
|
|
- `tsconfig.base.json` — add `@repo/auth*`, `@repo/media*` aliases
|
|
- `apps/cms/package.json` — add `@repo/auth`, `@repo/media` (for schema visibility)
|
|
|
|
---
|
|
|
|
## Phase A: Auth feature
|
|
|
|
### Task 3.1: Scaffold @repo/auth package
|
|
|
|
**Files:** `packages/auth/{package.json,tsconfig.json,turbo.json,vitest.config.ts,src/index.ts}` + path aliases.
|
|
|
|
- [ ] **Step 1: Create `packages/auth/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "@repo/auth",
|
|
"private": true,
|
|
"version": "0.0.0",
|
|
"type": "module",
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./cms": "./src/integrations/cms/index.ts",
|
|
"./api": "./src/integrations/api/router.ts"
|
|
},
|
|
"scripts": {
|
|
"build": "tsc --noEmit",
|
|
"lint": "eslint .",
|
|
"test": "vitest run --passWithNoTests",
|
|
"typecheck": "tsc --noEmit"
|
|
},
|
|
"dependencies": {
|
|
"@repo/core-shared": "workspace:*",
|
|
"@trpc/server": "^11.0.0",
|
|
"inversify": "^6.2.0",
|
|
"payload": "^3.14.0",
|
|
"reflect-metadata": "^0.2.2",
|
|
"zod": "^3.24.0"
|
|
},
|
|
"devDependencies": {
|
|
"@repo/eslint-config": "workspace:*",
|
|
"@repo/typescript-config": "workspace:*",
|
|
"@types/node": "^22.0.0",
|
|
"vitest": "^3.1.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
> Note: NO `@repo/core-cms` dep (cycle avoidance per Plan 2 lesson #4).
|
|
|
|
- [ ] **Step 2: Create `packages/auth/tsconfig.json`**
|
|
|
|
```json
|
|
{
|
|
"extends": "@repo/typescript-config/base.json",
|
|
"compilerOptions": {
|
|
"outDir": "dist",
|
|
"lib": ["ES2022", "DOM"],
|
|
"jsx": "preserve",
|
|
"paths": {
|
|
"@/*": ["./src/*"]
|
|
}
|
|
},
|
|
"include": ["src/**/*", "tests/**/*"],
|
|
"exclude": ["node_modules", "dist"]
|
|
}
|
|
```
|
|
|
|
> Note: NO `rootDir` (Plan 2 lesson #2). Path alias `@/` declared but only used in test files (Plan 2 lesson #3).
|
|
|
|
- [ ] **Step 3: Create `packages/auth/turbo.json`**
|
|
|
|
```json
|
|
{
|
|
"extends": ["//"],
|
|
"tags": ["feature"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create `packages/auth/vitest.config.ts`**
|
|
|
|
```typescript
|
|
import path from "node:path";
|
|
import { baseVitestConfig } from "@repo/typescript-config/vitest.base";
|
|
|
|
export default {
|
|
...baseVitestConfig,
|
|
resolve: {
|
|
alias: {
|
|
"@": path.resolve(__dirname, "./src"),
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Create `packages/auth/src/index.ts`**
|
|
|
|
```typescript
|
|
export {};
|
|
```
|
|
|
|
- [ ] **Step 6: Add path aliases to `tsconfig.base.json`**
|
|
|
|
Add three lines inside `compilerOptions.paths`:
|
|
|
|
```json
|
|
"@repo/auth": ["packages/auth/src/index.ts"],
|
|
"@repo/auth/cms": ["packages/auth/src/integrations/cms/index.ts"],
|
|
"@repo/auth/api": ["packages/auth/src/integrations/api/router.ts"]
|
|
```
|
|
|
|
- [ ] **Step 7: Install + verify**
|
|
|
|
Run: `pnpm install`
|
|
Verify: `pnpm list --recursive --depth=-1 | grep "@repo/auth"`
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add packages/auth tsconfig.base.json pnpm-lock.yaml
|
|
git commit -m "feat(auth): scaffold empty package with feature tag + path aliases"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.2: Auth entities (User, Cookie, Session, errors) + tests
|
|
|
|
**Files:** `packages/auth/src/entities/{user.ts,cookie.ts,session.ts,errors.ts}` + tests, `packages/auth/src/config.ts`.
|
|
|
|
- [ ] **Step 1: Write `packages/auth/src/entities/user.test.ts`**
|
|
|
|
```typescript
|
|
import { describe, expect, it } from "vitest";
|
|
import { userSchema } from "./user";
|
|
|
|
describe("userSchema", () => {
|
|
it("accepts a valid user", () => {
|
|
const result = userSchema.parse({
|
|
id: "1",
|
|
username: "alice",
|
|
passwordHash: "hashed_password_1",
|
|
});
|
|
expect(result.username).toBe("alice");
|
|
});
|
|
|
|
it("rejects username shorter than 3 chars", () => {
|
|
expect(() =>
|
|
userSchema.parse({
|
|
id: "1",
|
|
username: "ab",
|
|
passwordHash: "hashed_password_1",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
it("rejects passwordHash shorter than 6 chars", () => {
|
|
expect(() =>
|
|
userSchema.parse({
|
|
id: "1",
|
|
username: "alice",
|
|
passwordHash: "abc",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run — expect failure**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/entities/user.test.ts`
|
|
Expected: FAIL — Cannot find module './user'.
|
|
|
|
- [ ] **Step 3: Implement `packages/auth/src/entities/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 4: Implement `packages/auth/src/entities/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: Write `packages/auth/src/entities/session.test.ts`**
|
|
|
|
```typescript
|
|
import { describe, expect, it } from "vitest";
|
|
import { sessionSchema } from "./session";
|
|
|
|
describe("sessionSchema", () => {
|
|
it("accepts a valid session", () => {
|
|
const result = sessionSchema.parse({
|
|
id: "session_1",
|
|
userId: "1",
|
|
expiresAt: new Date(),
|
|
});
|
|
expect(result.userId).toBe("1");
|
|
});
|
|
|
|
it("rejects non-Date expiresAt", () => {
|
|
expect(() =>
|
|
sessionSchema.parse({
|
|
id: "session_1",
|
|
userId: "1",
|
|
expiresAt: "2026-05-04",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 6: Implement `packages/auth/src/entities/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 7: Implement `packages/auth/src/entities/errors.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);
|
|
}
|
|
}
|
|
|
|
export class InputParseError extends Error {
|
|
constructor(message: string, options?: ErrorOptions) {
|
|
super(message, options);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Implement `packages/auth/src/config.ts`**
|
|
|
|
```typescript
|
|
export const SESSION_COOKIE = "session";
|
|
```
|
|
|
|
- [ ] **Step 9: Run all entity tests — expect 5 PASS**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/entities`
|
|
Expected: 5 tests pass (user 3 + session 2).
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/entities packages/auth/src/config.ts
|
|
git commit -m "feat(auth): add User, Cookie, Session entities + errors + config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.3: Auth application interfaces (repository + service)
|
|
|
|
- [ ] **Step 1: Create `packages/auth/src/application/repositories/users-repository.interface.ts`**
|
|
|
|
```typescript
|
|
import type { User } from "../../entities/user";
|
|
|
|
export interface IUsersRepository {
|
|
getUser(id: string): Promise<User | undefined>;
|
|
getUserByUsername(username: string): Promise<User | undefined>;
|
|
createUser(input: User): Promise<User>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `packages/auth/src/application/services/authentication-service.interface.ts`**
|
|
|
|
```typescript
|
|
import type { Cookie } from "../../entities/cookie";
|
|
import type { Session } from "../../entities/session";
|
|
import type { User } from "../../entities/user";
|
|
|
|
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 3: Verify compiles**
|
|
|
|
Run: `cd packages/auth && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/application
|
|
git commit -m "feat(auth): add IUsersRepository + IAuthenticationService interfaces"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.4: Auth use-cases (sign-in, sign-up, sign-out) + tests
|
|
|
|
**Files:** 3 use-case files + 3 test files. Tests will be RED until DI container lands in Task 3.7.
|
|
|
|
- [ ] **Step 1: Create test `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "@/di/container";
|
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
import { AuthenticationError } from "@/entities/errors";
|
|
import { signInUseCase } from "./sign-in.use-case";
|
|
|
|
describe("signInUseCase", () => {
|
|
let usersRepo: MockUsersRepository;
|
|
let authService: MockAuthenticationService;
|
|
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
usersRepo = new MockUsersRepository();
|
|
authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("returns a session + cookie on valid credentials", async () => {
|
|
const result = await signInUseCase({
|
|
username: "alice",
|
|
password: "password_alice",
|
|
});
|
|
expect(result.session.userId).toBe("1");
|
|
expect(result.cookie.name).toBe("session");
|
|
});
|
|
|
|
it("throws AuthenticationError when user does not exist", async () => {
|
|
await expect(
|
|
signInUseCase({ username: "ghost", password: "anything" }),
|
|
).rejects.toBeInstanceOf(AuthenticationError);
|
|
});
|
|
|
|
it("throws AuthenticationError on wrong password", async () => {
|
|
await expect(
|
|
signInUseCase({ username: "alice", password: "wrong" }),
|
|
).rejects.toBeInstanceOf(AuthenticationError);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/auth/src/application/use-cases/sign-in.use-case.ts`**
|
|
|
|
```typescript
|
|
import { AuthenticationError } from "../../entities/errors";
|
|
import type { Cookie } from "../../entities/cookie";
|
|
import type { Session } from "../../entities/session";
|
|
import { authContainer } from "../../di/container";
|
|
import { AUTH_SYMBOLS } from "../../di/symbols";
|
|
import type { IUsersRepository } from "../repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "../services/authentication-service.interface";
|
|
|
|
export async function signInUseCase(input: {
|
|
username: string;
|
|
password: string;
|
|
}): Promise<{ session: Session; cookie: Cookie }> {
|
|
const usersRepository = authContainer.get<IUsersRepository>(
|
|
AUTH_SYMBOLS.IUsersRepository,
|
|
);
|
|
const authService = authContainer.get<IAuthenticationService>(
|
|
AUTH_SYMBOLS.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 3: Commit (test RED until 3.7)**
|
|
|
|
```bash
|
|
git add packages/auth/src/application/use-cases/sign-in.use-case.ts packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
|
git commit -m "feat(auth): add signInUseCase (test red until DI lands)"
|
|
```
|
|
|
|
- [ ] **Step 4: Create test `packages/auth/src/application/use-cases/sign-up.use-case.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "@/di/container";
|
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
import { AuthenticationError } from "@/entities/errors";
|
|
import { signUpUseCase } from "./sign-up.use-case";
|
|
|
|
describe("signUpUseCase", () => {
|
|
let usersRepo: MockUsersRepository;
|
|
let authService: MockAuthenticationService;
|
|
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
usersRepo = new MockUsersRepository();
|
|
authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("creates a new user and returns session + cookie + user", async () => {
|
|
const result = await signUpUseCase({
|
|
username: "carol",
|
|
password: "secret_password",
|
|
});
|
|
expect(result.user.username).toBe("carol");
|
|
expect(result.session.userId).toBe(result.user.id);
|
|
expect(result.cookie.name).toBe("session");
|
|
});
|
|
|
|
it("throws AuthenticationError when username taken", async () => {
|
|
await expect(
|
|
signUpUseCase({ username: "alice", password: "secret_password" }),
|
|
).rejects.toBeInstanceOf(AuthenticationError);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Implement `packages/auth/src/application/use-cases/sign-up.use-case.ts`**
|
|
|
|
```typescript
|
|
import { AuthenticationError } from "../../entities/errors";
|
|
import type { Cookie } from "../../entities/cookie";
|
|
import type { Session } from "../../entities/session";
|
|
import type { User } from "../../entities/user";
|
|
import { authContainer } from "../../di/container";
|
|
import { AUTH_SYMBOLS } from "../../di/symbols";
|
|
import type { IUsersRepository } from "../repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "../services/authentication-service.interface";
|
|
|
|
export async function signUpUseCase(input: {
|
|
username: string;
|
|
password: string;
|
|
}): Promise<{
|
|
session: Session;
|
|
cookie: Cookie;
|
|
user: Pick<User, "id" | "username">;
|
|
}> {
|
|
const usersRepository = authContainer.get<IUsersRepository>(
|
|
AUTH_SYMBOLS.IUsersRepository,
|
|
);
|
|
const authService = authContainer.get<IAuthenticationService>(
|
|
AUTH_SYMBOLS.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 6: Commit (test RED)**
|
|
|
|
```bash
|
|
git add packages/auth/src/application/use-cases/sign-up.use-case.ts packages/auth/src/application/use-cases/sign-up.use-case.test.ts
|
|
git commit -m "feat(auth): add signUpUseCase (test red until DI lands)"
|
|
```
|
|
|
|
- [ ] **Step 7: Create test `packages/auth/src/application/use-cases/sign-out.use-case.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "@/di/container";
|
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
import { signOutUseCase } from "./sign-out.use-case";
|
|
|
|
describe("signOutUseCase", () => {
|
|
let usersRepo: MockUsersRepository;
|
|
let authService: MockAuthenticationService;
|
|
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
usersRepo = new MockUsersRepository();
|
|
authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("returns a blank cookie", async () => {
|
|
const result = await signOutUseCase("session_1");
|
|
expect(result.blankCookie.name).toBe("session");
|
|
expect(result.blankCookie.value).toBe("");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 8: Implement `packages/auth/src/application/use-cases/sign-out.use-case.ts`**
|
|
|
|
```typescript
|
|
import type { Cookie } from "../../entities/cookie";
|
|
import { authContainer } from "../../di/container";
|
|
import { AUTH_SYMBOLS } from "../../di/symbols";
|
|
import type { IAuthenticationService } from "../services/authentication-service.interface";
|
|
|
|
export async function signOutUseCase(
|
|
sessionId: string,
|
|
): Promise<{ blankCookie: Cookie }> {
|
|
const authService = authContainer.get<IAuthenticationService>(
|
|
AUTH_SYMBOLS.IAuthenticationService,
|
|
);
|
|
return await authService.invalidateSession(sessionId);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/application/use-cases/sign-out.use-case.ts packages/auth/src/application/use-cases/sign-out.use-case.test.ts
|
|
git commit -m "feat(auth): add signOutUseCase (test red until DI lands)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.5: Mock users repository
|
|
|
|
- [ ] **Step 1: Create `packages/auth/src/infrastructure/repositories/mock-users.repository.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { injectable } from "inversify";
|
|
|
|
import type { IUsersRepository } from "../../application/repositories/users-repository.interface";
|
|
import type { User } from "../../entities/user";
|
|
|
|
@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: Verify compiles**
|
|
|
|
Run: `cd packages/auth && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/infrastructure/repositories
|
|
git commit -m "feat(auth): add MockUsersRepository with seed users"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.6: Mock authentication service
|
|
|
|
- [ ] **Step 1: Create `packages/auth/src/infrastructure/services/mock-authentication.service.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { inject, injectable } from "inversify";
|
|
|
|
import type { IAuthenticationService } from "../../application/services/authentication-service.interface";
|
|
import type { IUsersRepository } from "../../application/repositories/users-repository.interface";
|
|
import { UnauthenticatedError } from "../../entities/errors";
|
|
import { sessionSchema, type Session } from "../../entities/session";
|
|
import type { Cookie } from "../../entities/cookie";
|
|
import type { User } from "../../entities/user";
|
|
import { AUTH_SYMBOLS } from "../../di/symbols";
|
|
import { SESSION_COOKIE } from "../../config";
|
|
|
|
@injectable()
|
|
export class MockAuthenticationService implements IAuthenticationService {
|
|
private _sessions: Record<string, { session: Session; user: User }> = {};
|
|
|
|
constructor(
|
|
@inject(AUTH_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 2: Verify compiles**
|
|
|
|
Run: `cd packages/auth && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/infrastructure/services
|
|
git commit -m "feat(auth): add MockAuthenticationService with constructor-injected users repo"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.7: Per-feature DI container (symbols, module, container) + test
|
|
|
|
- [ ] **Step 1: Write `packages/auth/src/di/container.test.ts`**
|
|
|
|
```typescript
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "./container";
|
|
import { AUTH_SYMBOLS } from "./symbols";
|
|
import { AuthModule } from "./module";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
|
|
describe("authContainer", () => {
|
|
beforeEach(() => {
|
|
authContainer.unbindAll();
|
|
authContainer.load(AuthModule);
|
|
});
|
|
|
|
afterEach(() => {
|
|
authContainer.unbindAll();
|
|
});
|
|
|
|
it("resolves IUsersRepository to MockUsersRepository by default", () => {
|
|
const repo = authContainer.get<IUsersRepository>(
|
|
AUTH_SYMBOLS.IUsersRepository,
|
|
);
|
|
expect(repo).toBeInstanceOf(MockUsersRepository);
|
|
});
|
|
|
|
it("resolves IAuthenticationService to MockAuthenticationService by default", () => {
|
|
const service = authContainer.get<IAuthenticationService>(
|
|
AUTH_SYMBOLS.IAuthenticationService,
|
|
);
|
|
expect(service).toBeInstanceOf(MockAuthenticationService);
|
|
});
|
|
|
|
it("authentication service receives users repository via constructor injection", async () => {
|
|
const service = authContainer.get<IAuthenticationService>(
|
|
AUTH_SYMBOLS.IAuthenticationService,
|
|
);
|
|
// The service should be able to validate against the seeded users
|
|
const { session, cookie } = await service.createSession({
|
|
id: "1",
|
|
username: "alice",
|
|
passwordHash: "hashed_password_alice",
|
|
});
|
|
expect(session.userId).toBe("1");
|
|
expect(cookie.value).toBe(session.id);
|
|
|
|
// After session creation, validateSession should resolve user via the repo
|
|
const validated = await service.validateSession(session.id);
|
|
expect(validated.user.username).toBe("alice");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/auth/src/di/symbols.ts`**
|
|
|
|
```typescript
|
|
export const AUTH_SYMBOLS = {
|
|
IUsersRepository: Symbol.for("auth:IUsersRepository"),
|
|
IAuthenticationService: Symbol.for("auth:IAuthenticationService"),
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `packages/auth/src/di/module.ts`**
|
|
|
|
```typescript
|
|
import { ContainerModule, type interfaces } from "inversify";
|
|
|
|
import type { IUsersRepository } from "../application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "../application/services/authentication-service.interface";
|
|
import { MockUsersRepository } from "../infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "../infrastructure/services/mock-authentication.service";
|
|
import { AUTH_SYMBOLS } from "./symbols";
|
|
|
|
export const AuthModule = new ContainerModule((bind: interfaces.Bind) => {
|
|
bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository);
|
|
bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService).to(
|
|
MockAuthenticationService,
|
|
);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Implement `packages/auth/src/di/container.ts`**
|
|
|
|
```typescript
|
|
import "reflect-metadata";
|
|
import { Container } from "inversify";
|
|
import { AuthModule } from "./module";
|
|
|
|
export const authContainer = new Container({ defaultScope: "Singleton" });
|
|
authContainer.load(AuthModule);
|
|
```
|
|
|
|
- [ ] **Step 5: Run container test — expect 3 PASS**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/di/container.test.ts`
|
|
Expected: PASS — 3 tests.
|
|
|
|
- [ ] **Step 6: Run previously-RED use-case tests — expect 6 PASS**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/application/use-cases`
|
|
Expected: PASS — 6 tests (sign-in 3 + sign-up 2 + sign-out 1).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/di
|
|
git commit -m "feat(auth): add per-feature InversifyJS container with constructor-injected service"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.8: Auth controllers + tests
|
|
|
|
- [ ] **Step 1: Write test `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "@/di/container";
|
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
import { InputParseError } from "@/entities/errors";
|
|
import { signInController } from "./sign-in.controller";
|
|
|
|
describe("signInController", () => {
|
|
let usersRepo: MockUsersRepository;
|
|
let authService: MockAuthenticationService;
|
|
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
usersRepo = new MockUsersRepository();
|
|
authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("returns a cookie on valid credentials", async () => {
|
|
const cookie = await signInController({
|
|
username: "alice",
|
|
password: "password_alice",
|
|
});
|
|
expect(cookie.name).toBe("session");
|
|
});
|
|
|
|
it("throws InputParseError on missing username", async () => {
|
|
await expect(
|
|
signInController({ password: "anything" }),
|
|
).rejects.toBeInstanceOf(InputParseError);
|
|
});
|
|
|
|
it("throws InputParseError on too-short password", async () => {
|
|
await expect(
|
|
signInController({ username: "alice", password: "abc" }),
|
|
).rejects.toBeInstanceOf(InputParseError);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts`**
|
|
|
|
```typescript
|
|
import { z } from "zod";
|
|
|
|
import { InputParseError } from "../../entities/errors";
|
|
import type { Cookie } from "../../entities/cookie";
|
|
import { signInUseCase } from "../../application/use-cases/sign-in.use-case";
|
|
|
|
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 parsed = inputSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
|
|
}
|
|
const { cookie } = await signInUseCase(parsed.data);
|
|
return cookie;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run — expect 3 PASS**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-in.controller.test.ts`
|
|
Expected: PASS — 3 tests.
|
|
|
|
- [ ] **Step 4: Write test `packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "@/di/container";
|
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
import { InputParseError } from "@/entities/errors";
|
|
import { signUpController } from "./sign-up.controller";
|
|
|
|
describe("signUpController", () => {
|
|
let usersRepo: MockUsersRepository;
|
|
let authService: MockAuthenticationService;
|
|
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
usersRepo = new MockUsersRepository();
|
|
authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("creates a new user when passwords match", async () => {
|
|
const result = await signUpController({
|
|
username: "carol",
|
|
password: "secret_password",
|
|
confirmPassword: "secret_password",
|
|
});
|
|
expect(result.user.username).toBe("carol");
|
|
});
|
|
|
|
it("throws InputParseError when passwords do not match", async () => {
|
|
await expect(
|
|
signUpController({
|
|
username: "dave",
|
|
password: "secret_password",
|
|
confirmPassword: "different_password",
|
|
}),
|
|
).rejects.toBeInstanceOf(InputParseError);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Implement `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts`**
|
|
|
|
```typescript
|
|
import { z } from "zod";
|
|
|
|
import { InputParseError } from "../../entities/errors";
|
|
import { signUpUseCase } from "../../application/use-cases/sign-up.use-case";
|
|
|
|
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 parsed = inputSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
throw new InputParseError("Invalid sign-up input", { cause: parsed.error });
|
|
}
|
|
return await signUpUseCase(parsed.data);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run — expect 2 PASS**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-up.controller.test.ts`
|
|
Expected: PASS — 2 tests.
|
|
|
|
- [ ] **Step 7: Write test `packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "@/di/container";
|
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
import { InputParseError } from "@/entities/errors";
|
|
import { signOutController } from "./sign-out.controller";
|
|
|
|
describe("signOutController", () => {
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
const usersRepo = new MockUsersRepository();
|
|
const authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("returns a blank cookie", async () => {
|
|
const cookie = await signOutController("session_anything");
|
|
expect(cookie.name).toBe("session");
|
|
expect(cookie.value).toBe("");
|
|
});
|
|
|
|
it("throws InputParseError when sessionId is missing", async () => {
|
|
await expect(signOutController(undefined)).rejects.toBeInstanceOf(
|
|
InputParseError,
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 8: Implement `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts`**
|
|
|
|
```typescript
|
|
import { InputParseError } from "../../entities/errors";
|
|
import type { Cookie } from "../../entities/cookie";
|
|
import { signOutUseCase } from "../../application/use-cases/sign-out.use-case";
|
|
|
|
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 — expect 2 PASS**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/interface-adapters/controllers/sign-out.controller.test.ts`
|
|
Expected: PASS — 2 tests.
|
|
|
|
- [ ] **Step 10: Single commit for all 3 controllers**
|
|
|
|
```bash
|
|
git add packages/auth/src/interface-adapters
|
|
git commit -m "feat(auth): add sign-in/sign-up/sign-out controllers with Zod validation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.9: Auth CMS integration (Users collection)
|
|
|
|
- [ ] **Step 1: Create `packages/auth/src/integrations/cms/collections/users.ts`**
|
|
|
|
```typescript
|
|
import type { CollectionConfig } from "payload";
|
|
|
|
export const users: CollectionConfig = {
|
|
slug: "users",
|
|
auth: true,
|
|
admin: {
|
|
useAsTitle: "email",
|
|
},
|
|
fields: [
|
|
{
|
|
name: "displayName",
|
|
type: "text",
|
|
},
|
|
{
|
|
name: "role",
|
|
type: "select",
|
|
options: [
|
|
{ label: "Admin", value: "admin" },
|
|
{ label: "Editor", value: "editor" },
|
|
{ label: "Author", value: "author" },
|
|
],
|
|
defaultValue: "author",
|
|
required: true,
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
> Note: This is the Payload-managed admin auth collection. It is intentionally separate from the domain `User` entity (id/username/passwordHash) used by the auth feature's use-cases. Reconciling the two is future work; for now they coexist.
|
|
|
|
- [ ] **Step 2: Create `packages/auth/src/integrations/cms/index.ts`**
|
|
|
|
```typescript
|
|
export { users } from "./collections/users";
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compiles**
|
|
|
|
Run: `cd packages/auth && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/integrations/cms
|
|
git commit -m "feat(auth): add users collection with role + displayName fields"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.10: Auth API integration (tRPC router) + tests
|
|
|
|
- [ ] **Step 1: Write test `packages/auth/src/integrations/api/router.test.ts`**
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "@/di/container";
|
|
import { AUTH_SYMBOLS } from "@/di/symbols";
|
|
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "@/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "@/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "@/application/services/authentication-service.interface";
|
|
import { authRouter } from "./router";
|
|
|
|
describe("authRouter", () => {
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
const usersRepo = new MockUsersRepository();
|
|
const authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("exposes signIn, signUp, signOut procedures", () => {
|
|
const names = Object.keys(authRouter._def.procedures);
|
|
expect(names).toContain("signIn");
|
|
expect(names).toContain("signUp");
|
|
expect(names).toContain("signOut");
|
|
});
|
|
|
|
it("signIn returns a cookie", async () => {
|
|
const caller = authRouter.createCaller({});
|
|
const result = await caller.signIn({
|
|
username: "alice",
|
|
password: "password_alice",
|
|
});
|
|
expect(result.name).toBe("session");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `packages/auth/src/integrations/api/router.ts`**
|
|
|
|
```typescript
|
|
import { z } from "zod";
|
|
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
|
|
import { signInController } from "../../interface-adapters/controllers/sign-in.controller";
|
|
import { signUpController } from "../../interface-adapters/controllers/sign-up.controller";
|
|
import { signOutController } from "../../interface-adapters/controllers/sign-out.controller";
|
|
|
|
export const authRouter = router({
|
|
signIn: publicProcedure
|
|
.input(
|
|
z.object({
|
|
username: z.string().min(3).max(31),
|
|
password: z.string().min(6).max(255),
|
|
}),
|
|
)
|
|
.mutation(({ input }) => signInController(input)),
|
|
|
|
signUp: publicProcedure
|
|
.input(
|
|
z.object({
|
|
username: z.string().min(3).max(31),
|
|
password: z.string().min(6).max(255),
|
|
confirmPassword: z.string().min(6).max(255),
|
|
}),
|
|
)
|
|
.mutation(({ input }) => signUpController(input)),
|
|
|
|
signOut: publicProcedure
|
|
.input(z.object({ sessionId: z.string() }))
|
|
.mutation(({ input }) => signOutController(input.sessionId)),
|
|
});
|
|
|
|
export type AuthRouter = typeof authRouter;
|
|
```
|
|
|
|
- [ ] **Step 3: Run — expect 2 PASS**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run src/integrations/api/router.test.ts`
|
|
Expected: PASS — 2 tests.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/integrations/api
|
|
git commit -m "feat(auth): add tRPC router with signIn + signUp + signOut"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.11: ui/query.ts (placeholder for Plan 5 consumers) + barrel
|
|
|
|
- [ ] **Step 1: Create `packages/auth/src/ui/query.ts`**
|
|
|
|
```typescript
|
|
// React Query option builders for auth feature procedures.
|
|
// Sign-in/up/out are mutations — no query options needed.
|
|
// This file is intentionally minimal; expand if read procedures get added.
|
|
|
|
export {};
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `packages/auth/src/index.ts` with public surface**
|
|
|
|
```typescript
|
|
export type { User } from "./entities/user";
|
|
export type { Session } from "./entities/session";
|
|
export type { Cookie } from "./entities/cookie";
|
|
export {
|
|
AuthenticationError,
|
|
UnauthenticatedError,
|
|
UnauthorizedError,
|
|
InputParseError,
|
|
} from "./entities/errors";
|
|
export { SESSION_COOKIE } from "./config";
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compiles**
|
|
|
|
Run: `cd packages/auth && pnpm typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/src/ui packages/auth/src/index.ts
|
|
git commit -m "feat(auth): wire root barrel + ui/query stub"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.12: Auth feature-level test
|
|
|
|
- [ ] **Step 1: Create `packages/auth/tests/sign-in-flow.feature.test.ts`**
|
|
|
|
```typescript
|
|
// Feature-level test: sign-up, then sign-in with the new credentials, then sign-out.
|
|
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { authContainer } from "../src/di/container";
|
|
import { AUTH_SYMBOLS } from "../src/di/symbols";
|
|
import { MockUsersRepository } from "../src/infrastructure/repositories/mock-users.repository";
|
|
import { MockAuthenticationService } from "../src/infrastructure/services/mock-authentication.service";
|
|
import type { IUsersRepository } from "../src/application/repositories/users-repository.interface";
|
|
import type { IAuthenticationService } from "../src/application/services/authentication-service.interface";
|
|
import { authRouter } from "../src/integrations/api/router";
|
|
|
|
describe("auth feature: sign-up → sign-in → sign-out", () => {
|
|
beforeEach(() => {
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
|
}
|
|
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
|
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
|
}
|
|
const usersRepo = new MockUsersRepository();
|
|
const authService = new MockAuthenticationService(usersRepo);
|
|
authContainer
|
|
.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository)
|
|
.toConstantValue(usersRepo);
|
|
authContainer
|
|
.bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService)
|
|
.toConstantValue(authService);
|
|
});
|
|
|
|
it("a new user can sign up, then sign in, then sign out", async () => {
|
|
const caller = authRouter.createCaller({});
|
|
|
|
const signUpResult = await caller.signUp({
|
|
username: "newperson",
|
|
password: "verysecret",
|
|
confirmPassword: "verysecret",
|
|
});
|
|
expect(signUpResult.user.username).toBe("newperson");
|
|
const userId = signUpResult.user.id;
|
|
|
|
const signInCookie = await caller.signIn({
|
|
username: "newperson",
|
|
password: "verysecret",
|
|
});
|
|
expect(signInCookie.value).toBe("session_" + userId);
|
|
|
|
const signOutResult = await caller.signOut({
|
|
sessionId: signInCookie.value,
|
|
});
|
|
expect(signOutResult.value).toBe("");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run**
|
|
|
|
Run: `cd packages/auth && pnpm vitest run tests/`
|
|
Expected: PASS — 1 test.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/auth/tests
|
|
git commit -m "test(auth): add feature test for sign-up → sign-in → sign-out flow"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase B: Media feature (skeleton-only)
|
|
|
|
### Task 3.13: Scaffold @repo/media package
|
|
|
|
- [ ] **Step 1: Create `packages/media/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "@repo/media",
|
|
"private": true,
|
|
"version": "0.0.0",
|
|
"type": "module",
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./cms": "./src/integrations/cms/index.ts"
|
|
},
|
|
"scripts": {
|
|
"build": "tsc --noEmit",
|
|
"lint": "eslint .",
|
|
"test": "vitest run --passWithNoTests",
|
|
"typecheck": "tsc --noEmit"
|
|
},
|
|
"dependencies": {
|
|
"payload": "^3.14.0"
|
|
},
|
|
"devDependencies": {
|
|
"@repo/eslint-config": "workspace:*",
|
|
"@repo/typescript-config": "workspace:*",
|
|
"vitest": "^3.1.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
> Note: NO `./api` export — media has no tRPC procedures yet (no use-cases). Add when needed.
|
|
|
|
- [ ] **Step 2: Create `packages/media/tsconfig.json`**
|
|
|
|
```json
|
|
{
|
|
"extends": "@repo/typescript-config/base.json",
|
|
"compilerOptions": {
|
|
"outDir": "dist",
|
|
"lib": ["ES2022", "DOM"]
|
|
},
|
|
"include": ["src/**/*"],
|
|
"exclude": ["node_modules", "dist"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `packages/media/turbo.json`**
|
|
|
|
```json
|
|
{
|
|
"extends": ["//"],
|
|
"tags": ["feature"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create `packages/media/src/integrations/cms/collections/media.ts`**
|
|
|
|
```typescript
|
|
import type { CollectionConfig } from "payload";
|
|
|
|
export const media: CollectionConfig = {
|
|
slug: "media",
|
|
upload: {
|
|
mimeTypes: ["image/*", "application/pdf"],
|
|
},
|
|
admin: {
|
|
useAsTitle: "filename",
|
|
},
|
|
fields: [
|
|
{
|
|
name: "alt",
|
|
type: "text",
|
|
required: true,
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Create `packages/media/src/integrations/cms/index.ts`**
|
|
|
|
```typescript
|
|
export { media } from "./collections/media";
|
|
```
|
|
|
|
- [ ] **Step 6: Create `packages/media/src/index.ts`**
|
|
|
|
```typescript
|
|
export {};
|
|
```
|
|
|
|
- [ ] **Step 7: Add path aliases to `tsconfig.base.json`**
|
|
|
|
Add to `compilerOptions.paths`:
|
|
|
|
```json
|
|
"@repo/media": ["packages/media/src/index.ts"],
|
|
"@repo/media/cms": ["packages/media/src/integrations/cms/index.ts"]
|
|
```
|
|
|
|
- [ ] **Step 8: Install + verify**
|
|
|
|
Run: `pnpm install` then `pnpm typecheck --filter @repo/media`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add packages/media tsconfig.base.json pnpm-lock.yaml
|
|
git commit -m "feat(media): scaffold feature package with media collection only"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase C: Compose into core-cms + core-api, restore blog cross-feature relations
|
|
|
|
### Task 3.14: Wire users + media into core-cms
|
|
|
|
- [ ] **Step 1: Add `@repo/auth` and `@repo/media` to `packages/core-cms/package.json`**
|
|
|
|
Edit `dependencies` block — add both lines:
|
|
|
|
```json
|
|
"@repo/auth": "workspace:*",
|
|
"@repo/media": "workspace:*",
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `packages/core-cms/src/payload.config.ts`**
|
|
|
|
```typescript
|
|
import { buildConfig } from "payload";
|
|
import { postgresAdapter } from "@payloadcms/db-postgres";
|
|
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { users } from "@repo/auth/cms";
|
|
import { articles } from "@repo/blog/cms";
|
|
import { media } from "@repo/media/cms";
|
|
|
|
const filename = fileURLToPath(import.meta.url);
|
|
const dirname = path.dirname(filename);
|
|
|
|
export default buildConfig({
|
|
editor: lexicalEditor(),
|
|
collections: [users, articles, media],
|
|
globals: [],
|
|
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
|
db: postgresAdapter({
|
|
pool: {
|
|
connectionString:
|
|
process.env.DATABASE_URL ||
|
|
"postgresql://postgres:postgres@localhost:5432/template",
|
|
},
|
|
}),
|
|
typescript: {
|
|
outputFile: path.resolve(dirname, "generated-types.ts"),
|
|
},
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Install + typecheck**
|
|
|
|
Run: `pnpm install` then `pnpm typecheck --filter @repo/core-cms`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/core-cms pnpm-lock.yaml
|
|
git commit -m "feat(core-cms): compose users + media into payload config alongside articles"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.15: Restore blog's author + featuredImage relations
|
|
|
|
- [ ] **Step 1: Replace `packages/blog/src/integrations/cms/collections/articles.ts`**
|
|
|
|
```typescript
|
|
import type { CollectionConfig } from "payload";
|
|
import { slugifyIfMissing } from "@repo/core-shared/payload";
|
|
|
|
export const articles: CollectionConfig = {
|
|
slug: "articles",
|
|
admin: {
|
|
useAsTitle: "title",
|
|
defaultColumns: ["title", "status", "author", "updatedAt"],
|
|
},
|
|
hooks: {
|
|
beforeChange: [slugifyIfMissing],
|
|
},
|
|
versions: {
|
|
drafts: true,
|
|
},
|
|
fields: [
|
|
{
|
|
name: "title",
|
|
type: "text",
|
|
required: true,
|
|
maxLength: 255,
|
|
},
|
|
{
|
|
name: "slug",
|
|
type: "text",
|
|
unique: true,
|
|
admin: {
|
|
position: "sidebar",
|
|
description: "Auto-generated from title if left empty",
|
|
},
|
|
},
|
|
{
|
|
name: "content",
|
|
type: "richText",
|
|
},
|
|
{
|
|
name: "status",
|
|
type: "select",
|
|
options: [
|
|
{ label: "Draft", value: "draft" },
|
|
{ label: "Published", value: "published" },
|
|
],
|
|
defaultValue: "draft",
|
|
required: true,
|
|
admin: {
|
|
position: "sidebar",
|
|
},
|
|
},
|
|
{
|
|
name: "author",
|
|
type: "relationship",
|
|
relationTo: "users",
|
|
required: true,
|
|
admin: {
|
|
position: "sidebar",
|
|
},
|
|
},
|
|
{
|
|
name: "featuredImage",
|
|
type: "upload",
|
|
relationTo: "media",
|
|
},
|
|
{
|
|
name: "publishedAt",
|
|
type: "date",
|
|
admin: {
|
|
position: "sidebar",
|
|
date: {
|
|
pickerAppearance: "dayAndTime",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
> Note: `relationTo: "users"` and `relationTo: "media"` are slug references — they don't require importing `@repo/auth` or `@repo/media` from blog. Payload resolves them at runtime by looking up the collection slug in the assembled config.
|
|
|
|
- [ ] **Step 2: Verify blog still compiles + tests pass**
|
|
|
|
Run: `cd packages/blog && pnpm typecheck && pnpm test`
|
|
Expected: PASS — 26 tests.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add packages/blog/src/integrations/cms/collections/articles.ts
|
|
git commit -m "feat(blog): restore author relationship → users + featuredImage upload → media"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.16: Wire authRouter into core-api
|
|
|
|
- [ ] **Step 1: Add `@repo/auth` to `packages/core-api/package.json`**
|
|
|
|
Add inside `dependencies`:
|
|
|
|
```json
|
|
"@repo/auth": "workspace:*",
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `packages/core-api/src/root.ts`**
|
|
|
|
```typescript
|
|
import { router } from "@repo/core-shared/trpc/init";
|
|
import { authRouter } from "@repo/auth/api";
|
|
import { blogRouter } from "@repo/blog/api";
|
|
|
|
export const appRouter = router({
|
|
auth: authRouter,
|
|
blog: blogRouter,
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|
|
```
|
|
|
|
- [ ] **Step 3: Install + typecheck**
|
|
|
|
Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/auth`
|
|
Expected: BOTH PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/core-api pnpm-lock.yaml
|
|
git commit -m "feat(core-api): compose @repo/auth/api into appRouter under 'auth' namespace"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3.17: Final verification + apps/cms boot smoke
|
|
|
|
- [ ] **Step 1: Run all tests**
|
|
|
|
Run: `pnpm test --filter @repo/auth --filter @repo/blog --filter @repo/core-shared`
|
|
Expected: PASS. Auth: ~14 tests (entities 5 + DI container 3 + use-cases 6 + controllers 7 + router 2 + feature 1 = 24 — check actual count). Blog: 26. Core-shared: 26.
|
|
|
|
- [ ] **Step 2: Repo-wide typecheck**
|
|
|
|
Run: `pnpm typecheck`
|
|
Expected: PASS for blog, auth, media, core-shared, core-cms, core-api, core-trpc, core-ui, cms, cms-core. PRE-EXISTING failures remain in `@repo/api` and `@repo/ui`.
|
|
|
|
- [ ] **Step 3: Boot apps/cms admin smoke test**
|
|
|
|
Postgres: `docker ps | grep postgres` — should be running.
|
|
|
|
Start cms in background:
|
|
```bash
|
|
pnpm dev --filter @repo/cms &
|
|
DEV_PID=$!
|
|
sleep 15
|
|
```
|
|
|
|
Test endpoints:
|
|
```bash
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/users
|
|
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/media
|
|
```
|
|
Expected: ALL return `200`.
|
|
|
|
> Note: Payload may again offer schema push (drop article fields not in current config or add new user/media tables). Smoke test only cares about 200 responses; kill dev server after.
|
|
|
|
Kill: `kill $DEV_PID 2>/dev/null; pkill -f "next.*3001" 2>/dev/null`
|
|
|
|
- [ ] **Step 4: Regenerate types**
|
|
|
|
Run: `cd apps/cms && pnpm generate:types`
|
|
|
|
Verify both Articles + Users + Media types are present:
|
|
```bash
|
|
cd /Users/danijel/Documents/Projects/template-vertical/.worktrees/vertical-refactor
|
|
grep -c "Article\|User\|Media" packages/core-cms/src/generated-types.ts
|
|
```
|
|
Expected: count >= 10 (multiple references each).
|
|
|
|
- [ ] **Step 5: Commit regenerated types if changed**
|
|
|
|
```bash
|
|
git add packages/core-cms/src/generated-types.ts
|
|
git diff --cached --quiet || git commit -m "feat(core-cms): regenerate types — now includes Articles, Users, Media"
|
|
```
|
|
|
|
---
|
|
|
|
## Plan 3 Done Criteria
|
|
|
|
- [ ] All auth tests pass (~24 tests in `@repo/auth`)
|
|
- [ ] All blog tests pass (26)
|
|
- [ ] Media compiles (no tests yet — skeleton only)
|
|
- [ ] `apps/cms` admin serves all four endpoints (`/admin`, `/admin/collections/articles`, `/users`, `/media`) with 200
|
|
- [ ] `pnpm generate:types` produces a `generated-types.ts` containing Articles + Users + Media
|
|
- [ ] `core-cms/src/payload.config.ts` registers `users`, `articles`, `media` collections
|
|
- [ ] `core-api/src/root.ts` exposes both `auth.*` and `blog.*` namespaces
|
|
- [ ] Blog's `articles.ts` has `author: relationship → users` and `featuredImage: upload → media`
|
|
- [ ] No deletions yet (old `packages/core/src/entities/models/user.ts` etc. still exist — drained in Plan 6)
|
|
|
|
**Next plan:** Plan 4 — Marketing-pages + Navigation features. Adds the `pages` collection (with `featuredImage` relation to media), the `header` global, and the `siteSettings` global. No new cross-feature dependencies; should be quicker than Plan 3 since the patterns are well-established.
|