refactor(blog): factory-style use cases + per-use-case controllers + getArticleBySlug
- Use cases (create-article, get-articles, get-article-by-slug NEW) → factory functions - Controllers split: articles.controller.ts → 3 single-responsibility files - DI module wires factories with .toDynamicValue() - tRPC router resolves controllers via container Refactor log: §2, §3, §4.1, §4.2, §5.1 Spec: §6.2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,17 @@ Entity model moves (git mv — history preserved):
|
|||||||
- `packages/auth/src/infrastructure/services/authentication.service.ts` — real `AuthenticationService` using `node:crypto` for hashing/UUIDs; session methods deferred (see §7)
|
- `packages/auth/src/infrastructure/services/authentication.service.ts` — real `AuthenticationService` using `node:crypto` for hashing/UUIDs; session methods deferred (see §7)
|
||||||
- `packages/auth/src/infrastructure/services/authentication.service.test.ts` — tests for `generateUserId`, `hashPassword`/`verifyPassword` round-trip, and deferred-method error assertions
|
- `packages/auth/src/infrastructure/services/authentication.service.test.ts` — tests for `generateUserId`, `hashPassword`/`verifyPassword` round-trip, and deferred-method error assertions
|
||||||
|
|
||||||
|
### Task 5: Blog factory refactor — new files
|
||||||
|
|
||||||
|
- `packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts` — NEW use case factory; throws `ArticleNotFoundError` when slug is not found (previously the controller hit the repo directly, bypassing use-case error handling)
|
||||||
|
- `packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts` — 2 tests (slug found, slug missing → ArticleNotFoundError)
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/get-articles.controller.ts` — factory controller, replaces the `getArticlesController` function from `articles.controller.ts`
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts` — 3 tests (valid input, status filter, invalid shape → InputParseError)
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/create-article.controller.ts` — factory controller, replaces `createArticleController` from `articles.controller.ts`
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/create-article.controller.test.ts` — 3 tests (valid, missing title, missing authorId)
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts` — factory controller; now delegates to `getArticleBySlugUseCase` (which throws `ArticleNotFoundError`) instead of calling repo directly
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts` — 3 tests (found, not found → ArticleNotFoundError, empty slug → InputParseError)
|
||||||
|
|
||||||
### Task 2: Entities split — new error files
|
### Task 2: Entities split — new error files
|
||||||
|
|
||||||
- `packages/auth/src/entities/errors/auth.ts` — AuthenticationError, UnauthenticatedError, UnauthorizedError (split from errors.ts)
|
- `packages/auth/src/entities/errors/auth.ts` — AuthenticationError, UnauthenticatedError, UnauthorizedError (split from errors.ts)
|
||||||
@@ -107,6 +118,11 @@ Entity model moves (git mv — history preserved):
|
|||||||
|
|
||||||
## 3. Files deleted (with reason)
|
## 3. Files deleted (with reason)
|
||||||
|
|
||||||
|
### Task 5: Blog factory refactor — deleted files
|
||||||
|
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/articles.controller.ts` — multi-method controller replaced by 3 single-responsibility factory files (`get-articles.controller.ts`, `create-article.controller.ts`, `get-article-by-slug.controller.ts`)
|
||||||
|
- `packages/blog/src/interface-adapters/controllers/articles.controller.test.ts` — deleted with the controller; tests rewritten in the per-controller test files
|
||||||
|
|
||||||
### Task 2: Entities split — old errors.ts files removed
|
### Task 2: Entities split — old errors.ts files removed
|
||||||
|
|
||||||
- `packages/auth/src/entities/errors.ts` — replaced by `errors/auth.ts` + `errors/common.ts`
|
- `packages/auth/src/entities/errors.ts` — replaced by `errors/auth.ts` + `errors/common.ts`
|
||||||
@@ -118,18 +134,20 @@ Entity model moves (git mv — history preserved):
|
|||||||
|
|
||||||
### 4.1 Use cases — factory function pattern
|
### 4.1 Use cases — factory function pattern
|
||||||
|
|
||||||
Applied to all 3 auth use cases (`sign-in`, `sign-up`, `sign-out`):
|
Applied to all 3 auth use cases (`sign-in`, `sign-up`, `sign-out`) in Task 4, and all 3 blog use cases (`get-articles`, `create-article`, `get-article-by-slug` NEW) in Task 5:
|
||||||
|
|
||||||
- Use cases are now factory functions: `(deps) => async (input) => result`
|
- Use cases are now factory functions: `(deps) => async (input) => result`
|
||||||
- Each file exports `export type I*UseCase = ReturnType<typeof *UseCase>` for DI typing
|
- Each file exports `export type I*UseCase = ReturnType<typeof *UseCase>` for DI typing
|
||||||
- Use cases NO LONGER call `authContainer.get()` inside their bodies — all dependencies are passed as factory arguments
|
- Use cases NO LONGER call `*Container.get()` inside their bodies — all dependencies are passed as factory arguments
|
||||||
- Tests construct mocks directly: `const useCase = signInUseCase(mockUsers, mockAuth); await useCase(input);`
|
- Tests construct mocks directly: `const useCase = getArticlesUseCase(repo); await useCase({ status: "draft" });`
|
||||||
|
- NEW `getArticleBySlugUseCase`: previously the slug lookup bypassed the use case layer (controller called repo directly); now the use case owns the `ArticleNotFoundError` throw
|
||||||
|
|
||||||
### 4.2 Controllers — one per use case
|
### 4.2 Controllers — one per use case
|
||||||
|
|
||||||
Applied to all 3 auth controllers (`sign-in`, `sign-up`, `sign-out`):
|
Applied to all 3 auth controllers (`sign-in`, `sign-up`, `sign-out`) in Task 4; blog controllers split in Task 5:
|
||||||
|
|
||||||
- Controllers were already split (one file per use case) — Task 4 refactors them to factory functions
|
- Controllers were already split for auth (one file per use case) — Task 4 refactors them to factory functions
|
||||||
|
- Blog: the multi-method `articles.controller.ts` is deleted and replaced by 3 single-responsibility files
|
||||||
- Factory pattern: `(useCase: I*UseCase) => async (input) => result`
|
- Factory pattern: `(useCase: I*UseCase) => async (input) => result`
|
||||||
- Each exports `export type I*Controller = ReturnType<typeof *Controller>`
|
- Each exports `export type I*Controller = ReturnType<typeof *Controller>`
|
||||||
- Validation (Zod `safeParse`) stays inside the controller factory; throws `InputParseError` on failure
|
- Validation (Zod `safeParse`) stays inside the controller factory; throws `InputParseError` on failure
|
||||||
@@ -149,13 +167,20 @@ Pattern now in place across auth, blog, marketing-pages, navigation (media skipp
|
|||||||
|
|
||||||
### 5.1 Inversify `.toDynamicValue` bindings
|
### 5.1 Inversify `.toDynamicValue` bindings
|
||||||
|
|
||||||
Applied to `packages/auth/src/di/module.ts`:
|
Applied to `packages/auth/src/di/module.ts` (Task 4) and `packages/blog/src/di/module.ts` (Task 5):
|
||||||
|
|
||||||
|
**auth:**
|
||||||
- `AUTH_SYMBOLS` expanded with 6 new keys: `ISignInUseCase`, `ISignUpUseCase`, `ISignOutUseCase`, `ISignInController`, `ISignUpController`, `ISignOutController`
|
- `AUTH_SYMBOLS` expanded with 6 new keys: `ISignInUseCase`, `ISignUpUseCase`, `ISignOutUseCase`, `ISignInController`, `ISignUpController`, `ISignOutController`
|
||||||
- Use cases bound with `.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))` — dependencies resolved from the container at call time
|
- Use cases bound with `.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))` — dependencies resolved from the container at call time
|
||||||
- Controllers bound identically, taking the corresponding use case symbol from the container
|
- Controllers bound identically, taking the corresponding use case symbol from the container
|
||||||
- Repository and service bindings remain `.to(Mock*)` as the default
|
- Repository and service bindings remain `.to(Mock*)` as the default
|
||||||
|
|
||||||
|
**blog:**
|
||||||
|
- `BLOG_SYMBOLS` expanded with 6 new keys: `IGetArticlesUseCase`, `ICreateArticleUseCase`, `IGetArticleBySlugUseCase`, `IGetArticlesController`, `ICreateArticleController`, `IGetArticleBySlugController`
|
||||||
|
- All use cases and controllers bound with `.toDynamicValue()` — same pattern as auth
|
||||||
|
- Repository binding remains `.to(MockArticlesRepository)` as the default
|
||||||
|
- tRPC router (`integrations/api/router.ts`) updated to resolve controllers via `blogContainer.get<IXController>(BLOG_SYMBOLS.IXController)` instead of importing controllers directly
|
||||||
|
|
||||||
### 5.2 Mock siblings registered as default bindings
|
### 5.2 Mock siblings registered as default bindings
|
||||||
|
|
||||||
- `MockUsersRepository` and `MockAuthenticationService` remain the default bindings in `AuthModule`
|
- `MockUsersRepository` and `MockAuthenticationService` remain the default bindings in `AuthModule`
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { blogContainer } from "../../di/container";
|
import { createArticleUseCase } from "@/application/use-cases/create-article.use-case";
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
|
|
||||||
import { MockArticlesRepository } from "../../infrastructure/repositories/articles.repository.mock";
|
|
||||||
import { createArticleUseCase } from "./create-article.use-case";
|
|
||||||
|
|
||||||
describe("createArticleUseCase", () => {
|
describe("createArticleUseCase", () => {
|
||||||
let repo: MockArticlesRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
|
||||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
|
||||||
}
|
|
||||||
repo = new MockArticlesRepository();
|
|
||||||
blogContainer
|
|
||||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
|
||||||
.toConstantValue(repo);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates an article in draft status with auto-generated slug", async () => {
|
it("creates an article in draft status with auto-generated slug", async () => {
|
||||||
const result = await createArticleUseCase({
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = createArticleUseCase(repo);
|
||||||
|
|
||||||
|
const result = await useCase({
|
||||||
title: "Hello World",
|
title: "Hello World",
|
||||||
content: "body",
|
content: "body",
|
||||||
authorId: "u1",
|
authorId: "u1",
|
||||||
@@ -34,7 +22,10 @@ describe("createArticleUseCase", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses provided slug when supplied", async () => {
|
it("uses provided slug when supplied", async () => {
|
||||||
const result = await createArticleUseCase({
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = createArticleUseCase(repo);
|
||||||
|
|
||||||
|
const result = await useCase({
|
||||||
title: "Whatever",
|
title: "Whatever",
|
||||||
content: "body",
|
content: "body",
|
||||||
authorId: "u1",
|
authorId: "u1",
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { Article } from "../../entities/models/article";
|
import type { Article } from "../../entities/models/article";
|
||||||
import { blogContainer } from "../../di/container";
|
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
|
||||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||||
|
|
||||||
function generateSlug(title: string): string {
|
function generateSlug(title: string): string {
|
||||||
@@ -10,27 +8,27 @@ function generateSlug(title: string): string {
|
|||||||
.replace(/^-+|-+$/g, "");
|
.replace(/^-+|-+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createArticleUseCase(input: {
|
export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
|
||||||
title: string;
|
|
||||||
content?: unknown;
|
|
||||||
authorId: string;
|
|
||||||
slug?: string;
|
|
||||||
}): Promise<Article> {
|
|
||||||
const repo = blogContainer.get<IArticlesRepository>(
|
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
const now = new Date();
|
export const createArticleUseCase =
|
||||||
const article: Article = {
|
(articlesRepository: IArticlesRepository) =>
|
||||||
id: crypto.randomUUID(),
|
async (input: {
|
||||||
title: input.title,
|
title: string;
|
||||||
slug: input.slug ?? generateSlug(input.title),
|
content?: unknown;
|
||||||
content: input.content,
|
authorId: string;
|
||||||
status: "draft",
|
slug?: string;
|
||||||
authorId: input.authorId,
|
}): Promise<Article> => {
|
||||||
createdAt: now,
|
const now = new Date();
|
||||||
updatedAt: now,
|
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 articlesRepository.createArticle(article);
|
||||||
};
|
};
|
||||||
|
|
||||||
return repo.createArticle(article);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case";
|
||||||
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
|
import { ArticleNotFoundError } from "@/entities/errors/article";
|
||||||
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
|
|
||||||
|
describe("getArticleBySlugUseCase", () => {
|
||||||
|
it("returns the article when slug exists", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const seed = articleFactory.build({ slug: "test-slug" });
|
||||||
|
await repo.createArticle(seed);
|
||||||
|
|
||||||
|
const useCase = getArticleBySlugUseCase(repo);
|
||||||
|
const result = await useCase({ slug: "test-slug" });
|
||||||
|
|
||||||
|
expect(result?.slug).toBe("test-slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ArticleNotFoundError when slug is missing", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = getArticleBySlugUseCase(repo);
|
||||||
|
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(ArticleNotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||||||
|
import type { Article } from "../../entities/models/article";
|
||||||
|
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||||
|
|
||||||
|
export type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;
|
||||||
|
|
||||||
|
export const getArticleBySlugUseCase =
|
||||||
|
(articlesRepository: IArticlesRepository) =>
|
||||||
|
async (input: { slug: string }): Promise<Article> => {
|
||||||
|
const article = await articlesRepository.getArticleBySlug(input.slug);
|
||||||
|
if (!article) {
|
||||||
|
throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`);
|
||||||
|
}
|
||||||
|
return article;
|
||||||
|
};
|
||||||
@@ -1,36 +1,28 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { blogContainer } from "../../di/container";
|
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
import { MockArticlesRepository } from "../../infrastructure/repositories/articles.repository.mock";
|
|
||||||
import { articleFactory } from "../../__factories__/article.factory";
|
|
||||||
import { getArticlesUseCase } from "./get-articles.use-case";
|
|
||||||
|
|
||||||
describe("getArticlesUseCase", () => {
|
describe("getArticlesUseCase", () => {
|
||||||
let repo: MockArticlesRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
|
||||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
|
||||||
}
|
|
||||||
repo = new MockArticlesRepository();
|
|
||||||
blogContainer
|
|
||||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
|
||||||
.toConstantValue(repo);
|
|
||||||
articleFactory.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all articles with no filters", async () => {
|
it("returns all articles with no filters", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
articleFactory.reset();
|
||||||
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
|
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
|
||||||
const result = await getArticlesUseCase();
|
|
||||||
|
const useCase = getArticlesUseCase(repo);
|
||||||
|
const result = await useCase();
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]?.id).toBe("1");
|
expect(result[0]?.id).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by status", async () => {
|
it("filters by status", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
articleFactory.reset();
|
||||||
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }));
|
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a", status: "draft" }));
|
||||||
await repo.createArticle(articleFactory.build({ id: "2", title: "B", slug: "b", status: "published" }));
|
await repo.createArticle(articleFactory.build({ id: "2", title: "B", slug: "b", status: "published" }));
|
||||||
const result = await getArticlesUseCase({ status: "published" });
|
|
||||||
|
const useCase = getArticlesUseCase(repo);
|
||||||
|
const result = await useCase({ status: "published" });
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]?.id).toBe("2");
|
expect(result[0]?.id).toBe("2");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { Article } from "../../entities/models/article";
|
import type { Article } from "../../entities/models/article";
|
||||||
import { blogContainer } from "../../di/container";
|
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
|
||||||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||||||
|
|
||||||
export async function getArticlesUseCase(options?: {
|
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||||||
status?: string;
|
|
||||||
authorId?: string;
|
export const getArticlesUseCase =
|
||||||
limit?: number;
|
(articlesRepository: IArticlesRepository) =>
|
||||||
offset?: number;
|
async (options?: {
|
||||||
}): Promise<Article[]> {
|
status?: string;
|
||||||
const repo = blogContainer.get<IArticlesRepository>(
|
authorId?: string;
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
limit?: number;
|
||||||
);
|
offset?: number;
|
||||||
return repo.getArticles(options);
|
}): Promise<Article[]> => {
|
||||||
}
|
return articlesRepository.getArticles(options);
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,15 +22,18 @@ describe("blogContainer", () => {
|
|||||||
expect(repo).toBeInstanceOf(MockArticlesRepository);
|
expect(repo).toBeInstanceOf(MockArticlesRepository);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports rebinding to a custom repo", () => {
|
it("resolves IGetArticlesController from the container", () => {
|
||||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticlesController);
|
||||||
const custom = new MockArticlesRepository();
|
expect(typeof ctrl).toBe("function");
|
||||||
blogContainer
|
});
|
||||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
|
||||||
.toConstantValue(custom);
|
it("resolves ICreateArticleController from the container", () => {
|
||||||
const resolved = blogContainer.get<IArticlesRepository>(
|
const ctrl = blogContainer.get(BLOG_SYMBOLS.ICreateArticleController);
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
expect(typeof ctrl).toBe("function");
|
||||||
);
|
});
|
||||||
expect(resolved).toBe(custom);
|
|
||||||
|
it("resolves IGetArticleBySlugController from the container", () => {
|
||||||
|
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticleBySlugController);
|
||||||
|
expect(typeof ctrl).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,68 @@ import { ContainerModule, type interfaces } from "inversify";
|
|||||||
|
|
||||||
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
|
||||||
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock";
|
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock";
|
||||||
|
import {
|
||||||
|
getArticlesUseCase,
|
||||||
|
type IGetArticlesUseCase,
|
||||||
|
} from "../application/use-cases/get-articles.use-case";
|
||||||
|
import {
|
||||||
|
createArticleUseCase,
|
||||||
|
type ICreateArticleUseCase,
|
||||||
|
} from "../application/use-cases/create-article.use-case";
|
||||||
|
import {
|
||||||
|
getArticleBySlugUseCase,
|
||||||
|
type IGetArticleBySlugUseCase,
|
||||||
|
} from "../application/use-cases/get-article-by-slug.use-case";
|
||||||
|
import {
|
||||||
|
getArticlesController,
|
||||||
|
type IGetArticlesController,
|
||||||
|
} from "../interface-adapters/controllers/get-articles.controller";
|
||||||
|
import {
|
||||||
|
createArticleController,
|
||||||
|
type ICreateArticleController,
|
||||||
|
} from "../interface-adapters/controllers/create-article.controller";
|
||||||
|
import {
|
||||||
|
getArticleBySlugController,
|
||||||
|
type IGetArticleBySlugController,
|
||||||
|
} from "../interface-adapters/controllers/get-article-by-slug.controller";
|
||||||
import { BLOG_SYMBOLS } from "./symbols";
|
import { BLOG_SYMBOLS } from "./symbols";
|
||||||
|
|
||||||
export const BlogModule = new ContainerModule((bind: interfaces.Bind) => {
|
export const BlogModule = new ContainerModule((bind: interfaces.Bind) => {
|
||||||
bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(
|
bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository);
|
||||||
MockArticlesRepository,
|
|
||||||
|
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue((ctx) =>
|
||||||
|
getArticlesUseCase(
|
||||||
|
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bind<ICreateArticleUseCase>(BLOG_SYMBOLS.ICreateArticleUseCase).toDynamicValue((ctx) =>
|
||||||
|
createArticleUseCase(
|
||||||
|
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bind<IGetArticleBySlugUseCase>(BLOG_SYMBOLS.IGetArticleBySlugUseCase).toDynamicValue((ctx) =>
|
||||||
|
getArticleBySlugUseCase(
|
||||||
|
ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bind<IGetArticlesController>(BLOG_SYMBOLS.IGetArticlesController).toDynamicValue((ctx) =>
|
||||||
|
getArticlesController(
|
||||||
|
ctx.container.get<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bind<ICreateArticleController>(BLOG_SYMBOLS.ICreateArticleController).toDynamicValue((ctx) =>
|
||||||
|
createArticleController(
|
||||||
|
ctx.container.get<ICreateArticleUseCase>(BLOG_SYMBOLS.ICreateArticleUseCase),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bind<IGetArticleBySlugController>(BLOG_SYMBOLS.IGetArticleBySlugController).toDynamicValue((ctx) =>
|
||||||
|
getArticleBySlugController(
|
||||||
|
ctx.container.get<IGetArticleBySlugUseCase>(BLOG_SYMBOLS.IGetArticleBySlugUseCase),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
export const BLOG_SYMBOLS = {
|
export const BLOG_SYMBOLS = {
|
||||||
IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
|
IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
|
||||||
|
// Use cases
|
||||||
|
IGetArticlesUseCase: Symbol.for("blog:IGetArticlesUseCase"),
|
||||||
|
ICreateArticleUseCase: Symbol.for("blog:ICreateArticleUseCase"),
|
||||||
|
IGetArticleBySlugUseCase: Symbol.for("blog:IGetArticleBySlugUseCase"),
|
||||||
|
// Controllers
|
||||||
|
IGetArticlesController: Symbol.for("blog:IGetArticlesController"),
|
||||||
|
ICreateArticleController: Symbol.for("blog:ICreateArticleController"),
|
||||||
|
IGetArticleBySlugController: Symbol.for("blog:IGetArticleBySlugController"),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { blogContainer } from "../../di/container";
|
import { blogContainer } from "../../di/container";
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
import { BlogModule } from "../../di/module";
|
||||||
import { MockArticlesRepository } from "../../infrastructure/repositories/articles.repository.mock";
|
|
||||||
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
|
|
||||||
import { blogRouter } from "./router";
|
import { blogRouter } from "./router";
|
||||||
import { articleFactory } from "../../__factories__/article.factory.js";
|
|
||||||
|
|
||||||
|
// The router resolves controllers from blogContainer (a singleton).
|
||||||
|
// We reload the module between tests to get a fresh MockArticlesRepository.
|
||||||
describe("blogRouter", () => {
|
describe("blogRouter", () => {
|
||||||
let repo: MockArticlesRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
blogContainer.unbindAll();
|
||||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
blogContainer.load(BlogModule);
|
||||||
}
|
});
|
||||||
repo = new MockArticlesRepository();
|
|
||||||
blogContainer
|
afterEach(() => {
|
||||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
blogContainer.unbindAll();
|
||||||
.toConstantValue(repo);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes articleBySlug, listArticles, createArticle procedures", () => {
|
it("exposes articleBySlug, listArticles, createArticle procedures", () => {
|
||||||
@@ -26,19 +22,24 @@ describe("blogRouter", () => {
|
|||||||
expect(procedureNames).toContain("createArticle");
|
expect(procedureNames).toContain("createArticle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("articleBySlug returns the article when present", async () => {
|
it("listArticles returns empty array by default", async () => {
|
||||||
await repo.createArticle(
|
|
||||||
articleFactory.build({ id: "1", title: "T", slug: "t", authorId: "u1" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const caller = blogRouter.createCaller({});
|
|
||||||
const result = await caller.articleBySlug({ slug: "t" });
|
|
||||||
expect(result?.id).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listArticles returns all articles when no input is given", async () => {
|
|
||||||
const caller = blogRouter.createCaller({});
|
const caller = blogRouter.createCaller({});
|
||||||
const result = await caller.listArticles();
|
const result = await caller.listArticles();
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("createArticle then articleBySlug returns the article", async () => {
|
||||||
|
const caller = blogRouter.createCaller({});
|
||||||
|
|
||||||
|
const created = await caller.createArticle({
|
||||||
|
title: "Router Test Article",
|
||||||
|
content: null,
|
||||||
|
authorId: "u1",
|
||||||
|
slug: "router-test",
|
||||||
|
});
|
||||||
|
expect(created.slug).toBe("router-test");
|
||||||
|
|
||||||
|
const fetched = await caller.articleBySlug({ slug: "router-test" });
|
||||||
|
expect(fetched.id).toBe(created.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
|
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
|
||||||
import {
|
import { blogContainer } from "../../di/container";
|
||||||
createArticleController,
|
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||||||
getArticlesController,
|
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
|
||||||
getArticleBySlugController,
|
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
||||||
} from "../../interface-adapters/controllers/articles.controller";
|
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||||||
|
|
||||||
export const blogRouter = router({
|
export const blogRouter = router({
|
||||||
articleBySlug: publicProcedure
|
articleBySlug: publicProcedure
|
||||||
.input(z.object({ slug: z.string().min(1) }))
|
.input(z.object({ slug: z.string().min(1) }))
|
||||||
.query(({ input }) => getArticleBySlugController(input)),
|
.query(({ input }) => {
|
||||||
|
const ctrl = blogContainer.get<IGetArticleBySlugController>(
|
||||||
|
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||||||
|
);
|
||||||
|
return ctrl(input);
|
||||||
|
}),
|
||||||
|
|
||||||
listArticles: publicProcedure
|
listArticles: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -22,7 +27,12 @@ export const blogRouter = router({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
.query(({ input }) => getArticlesController(input ?? {})),
|
.query(({ input }) => {
|
||||||
|
const ctrl = blogContainer.get<IGetArticlesController>(
|
||||||
|
BLOG_SYMBOLS.IGetArticlesController,
|
||||||
|
);
|
||||||
|
return ctrl(input ?? {});
|
||||||
|
}),
|
||||||
|
|
||||||
createArticle: publicProcedure
|
createArticle: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -33,7 +43,12 @@ export const blogRouter = router({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(({ input }) => createArticleController(input)),
|
.mutation(({ input }) => {
|
||||||
|
const ctrl = blogContainer.get<ICreateArticleController>(
|
||||||
|
BLOG_SYMBOLS.ICreateArticleController,
|
||||||
|
);
|
||||||
|
return ctrl(input);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BlogRouter = typeof blogRouter;
|
export type BlogRouter = typeof blogRouter;
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import { blogContainer } from "../../di/container";
|
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
|
||||||
import { MockArticlesRepository } from "../../infrastructure/repositories/articles.repository.mock";
|
|
||||||
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
|
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
|
||||||
import {
|
|
||||||
createArticleController,
|
|
||||||
getArticlesController,
|
|
||||||
getArticleBySlugController,
|
|
||||||
} from "./articles.controller";
|
|
||||||
|
|
||||||
describe("articles controller", () => {
|
|
||||||
let repo: MockArticlesRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
|
||||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
|
||||||
}
|
|
||||||
repo = new MockArticlesRepository();
|
|
||||||
blogContainer
|
|
||||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
|
||||||
.toConstantValue(repo);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createArticleController", () => {
|
|
||||||
it("creates an article on valid input", async () => {
|
|
||||||
const result = await createArticleController({
|
|
||||||
title: "Hello",
|
|
||||||
content: "body",
|
|
||||||
authorId: "u1",
|
|
||||||
});
|
|
||||||
expect(result.title).toBe("Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws InputParseError on missing title", async () => {
|
|
||||||
await expect(
|
|
||||||
createArticleController({ content: "body", authorId: "u1" }),
|
|
||||||
).rejects.toBeInstanceOf(InputParseError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getArticlesController", () => {
|
|
||||||
it("returns array on valid input", async () => {
|
|
||||||
const result = await getArticlesController({});
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws InputParseError on invalid input shape", async () => {
|
|
||||||
await expect(
|
|
||||||
getArticlesController({ limit: "not a number" } as unknown as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>),
|
|
||||||
).rejects.toBeInstanceOf(InputParseError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getArticleBySlugController", () => {
|
|
||||||
it("returns undefined for missing slug", async () => {
|
|
||||||
const result = await getArticleBySlugController({ slug: "nope" });
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws InputParseError on missing slug", async () => {
|
|
||||||
await expect(
|
|
||||||
getArticleBySlugController({} as { slug: string }),
|
|
||||||
).rejects.toBeInstanceOf(InputParseError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
|
||||||
import type { Article } from "../../entities/models/article";
|
|
||||||
import { blogContainer } from "../../di/container";
|
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
|
||||||
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
|
|
||||||
import { getArticlesUseCase } from "../../application/use-cases/get-articles.use-case";
|
|
||||||
import { createArticleUseCase } from "../../application/use-cases/create-article.use-case";
|
|
||||||
|
|
||||||
const createInputSchema = z.object({
|
|
||||||
title: z.string().min(1).max(255),
|
|
||||||
content: z.unknown().optional(),
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getBySlugInputSchema = z.object({
|
|
||||||
slug: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function createArticleController(
|
|
||||||
input: Partial<z.infer<typeof createInputSchema>>,
|
|
||||||
): Promise<Article> {
|
|
||||||
const parsed = createInputSchema.safeParse(input);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new InputParseError("Invalid create-article input", {
|
|
||||||
cause: parsed.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return createArticleUseCase({
|
|
||||||
title: parsed.data.title,
|
|
||||||
content: parsed.data.content ?? null,
|
|
||||||
authorId: parsed.data.authorId,
|
|
||||||
slug: parsed.data.slug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArticlesController(
|
|
||||||
input: Partial<z.infer<typeof getInputSchema>>,
|
|
||||||
): Promise<Article[]> {
|
|
||||||
const parsed = getInputSchema.safeParse(input);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new InputParseError("Invalid get-articles input", {
|
|
||||||
cause: parsed.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return getArticlesUseCase(parsed.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArticleBySlugController(input: {
|
|
||||||
slug: string;
|
|
||||||
}): Promise<Article | undefined> {
|
|
||||||
const parsed = getBySlugInputSchema.safeParse(input);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new InputParseError("Invalid get-article-by-slug input", {
|
|
||||||
cause: parsed.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const repo = blogContainer.get<IArticlesRepository>(
|
|
||||||
BLOG_SYMBOLS.IArticlesRepository,
|
|
||||||
);
|
|
||||||
return repo.getArticleBySlug(parsed.data.slug);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createArticleController } from "@/interface-adapters/controllers/create-article.controller";
|
||||||
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
|
import { createArticleUseCase } from "@/application/use-cases/create-article.use-case";
|
||||||
|
import { InputParseError } from "@/entities/errors/common";
|
||||||
|
|
||||||
|
describe("createArticleController", () => {
|
||||||
|
it("creates an article on valid input", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = createArticleUseCase(repo);
|
||||||
|
const controller = createArticleController(useCase);
|
||||||
|
|
||||||
|
const result = await controller({ title: "Hello", content: "body", authorId: "u1" });
|
||||||
|
expect(result.title).toBe("Hello");
|
||||||
|
expect(result.slug).toBe("hello");
|
||||||
|
expect(result.status).toBe("draft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws InputParseError on missing title", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = createArticleUseCase(repo);
|
||||||
|
const controller = createArticleController(useCase);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller({ content: "body", authorId: "u1" }),
|
||||||
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws InputParseError on missing authorId", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = createArticleUseCase(repo);
|
||||||
|
const controller = createArticleController(useCase);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller({ title: "T" }),
|
||||||
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
|
import type { Article } from "../../entities/models/article";
|
||||||
|
import type { ICreateArticleUseCase } from "../../application/use-cases/create-article.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
title: z.string().min(1).max(255),
|
||||||
|
content: z.unknown().optional(),
|
||||||
|
authorId: z.string(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ICreateArticleController = ReturnType<typeof createArticleController>;
|
||||||
|
|
||||||
|
export const createArticleController =
|
||||||
|
(createArticleUseCase: ICreateArticleUseCase) =>
|
||||||
|
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Article> => {
|
||||||
|
const parsed = inputSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new InputParseError("Invalid create-article input", { cause: parsed.error });
|
||||||
|
}
|
||||||
|
return createArticleUseCase({
|
||||||
|
title: parsed.data.title,
|
||||||
|
content: parsed.data.content ?? null,
|
||||||
|
authorId: parsed.data.authorId,
|
||||||
|
slug: parsed.data.slug,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getArticleBySlugController } from "@/interface-adapters/controllers/get-article-by-slug.controller";
|
||||||
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
|
import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case";
|
||||||
|
import { InputParseError } from "@/entities/errors/common";
|
||||||
|
import { ArticleNotFoundError } from "@/entities/errors/article";
|
||||||
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
|
|
||||||
|
describe("getArticleBySlugController", () => {
|
||||||
|
it("returns article when slug exists", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const seed = articleFactory.build({ slug: "test-slug" });
|
||||||
|
await repo.createArticle(seed);
|
||||||
|
|
||||||
|
const useCase = getArticleBySlugUseCase(repo);
|
||||||
|
const controller = getArticleBySlugController(useCase);
|
||||||
|
|
||||||
|
const result = await controller({ slug: "test-slug" });
|
||||||
|
expect(result.slug).toBe("test-slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ArticleNotFoundError for missing slug", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = getArticleBySlugUseCase(repo);
|
||||||
|
const controller = getArticleBySlugController(useCase);
|
||||||
|
|
||||||
|
await expect(controller({ slug: "nope" })).rejects.toBeInstanceOf(ArticleNotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws InputParseError on empty slug", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = getArticleBySlugUseCase(repo);
|
||||||
|
const controller = getArticleBySlugController(useCase);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller({} as { slug: string }),
|
||||||
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
|
import type { Article } from "../../entities/models/article";
|
||||||
|
import type { IGetArticleBySlugUseCase } from "../../application/use-cases/get-article-by-slug.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
|
||||||
|
|
||||||
|
export const getArticleBySlugController =
|
||||||
|
(getArticleBySlugUseCase: IGetArticleBySlugUseCase) =>
|
||||||
|
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Article> => {
|
||||||
|
const parsed = inputSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
|
||||||
|
}
|
||||||
|
return getArticleBySlugUseCase(parsed.data);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getArticlesController } from "@/interface-adapters/controllers/get-articles.controller";
|
||||||
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
|
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||||
|
import { InputParseError } from "@/entities/errors/common";
|
||||||
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
|
|
||||||
|
describe("getArticlesController", () => {
|
||||||
|
it("returns array on valid input", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = getArticlesUseCase(repo);
|
||||||
|
const controller = getArticlesController(useCase);
|
||||||
|
|
||||||
|
const result = await controller({});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by status", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
articleFactory.reset();
|
||||||
|
await repo.createArticle(articleFactory.build({ id: "1", slug: "a", status: "draft" }));
|
||||||
|
await repo.createArticle(articleFactory.build({ id: "2", slug: "b", status: "published" }));
|
||||||
|
|
||||||
|
const useCase = getArticlesUseCase(repo);
|
||||||
|
const controller = getArticlesController(useCase);
|
||||||
|
|
||||||
|
const result = await controller({ status: "published" });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.id).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws InputParseError on invalid input shape", async () => {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const useCase = getArticlesUseCase(repo);
|
||||||
|
const controller = getArticlesController(useCase);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller({ limit: "not a number" } as unknown as Record<string, unknown>),
|
||||||
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
|
import type { Article } from "../../entities/models/article";
|
||||||
|
import type { IGetArticlesUseCase } from "../../application/use-cases/get-articles.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
status: z.string().optional(),
|
||||||
|
authorId: z.string().optional(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
offset: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
||||||
|
|
||||||
|
export const getArticlesController =
|
||||||
|
(getArticlesUseCase: IGetArticlesUseCase) =>
|
||||||
|
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Article[]> => {
|
||||||
|
const parsed = inputSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new InputParseError("Invalid get-articles input", { cause: parsed.error });
|
||||||
|
}
|
||||||
|
return getArticlesUseCase(parsed.data);
|
||||||
|
};
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
// Feature-level test: exercises the full slice
|
// Feature-level test: exercises the full slice
|
||||||
// tRPC procedure -> controller -> use-case -> mock repo
|
// use case factory chain → mock repo
|
||||||
// without going through a network or the actual Payload Local API.
|
// Tests the full dependency chain via direct injection (no container rebinding).
|
||||||
// Verifies that the layers are correctly wired through the per-feature DI container.
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { blogContainer } from "../src/di/container";
|
|
||||||
import { BLOG_SYMBOLS } from "../src/di/symbols";
|
|
||||||
import { MockArticlesRepository } from "../src/infrastructure/repositories/articles.repository.mock";
|
import { MockArticlesRepository } from "../src/infrastructure/repositories/articles.repository.mock";
|
||||||
import type { IArticlesRepository } from "../src/application/repositories/articles.repository.interface";
|
import { getArticlesUseCase } from "../src/application/use-cases/get-articles.use-case";
|
||||||
import { blogRouter } from "../src/integrations/api/router";
|
import { createArticleUseCase } from "../src/application/use-cases/create-article.use-case";
|
||||||
|
import { getArticleBySlugUseCase } from "../src/application/use-cases/get-article-by-slug.use-case";
|
||||||
|
import { getArticlesController } from "../src/interface-adapters/controllers/get-articles.controller";
|
||||||
|
import { createArticleController } from "../src/interface-adapters/controllers/create-article.controller";
|
||||||
|
import { getArticleBySlugController } from "../src/interface-adapters/controllers/get-article-by-slug.controller";
|
||||||
|
import { ArticleNotFoundError } from "../src/entities/errors/article";
|
||||||
|
|
||||||
describe("blog feature: article-by-slug end-to-end", () => {
|
describe("blog feature: article end-to-end via direct injection", () => {
|
||||||
let repo: MockArticlesRepository;
|
function buildChain() {
|
||||||
|
const repo = new MockArticlesRepository();
|
||||||
|
const createUseCase = createArticleUseCase(repo);
|
||||||
|
const getArticlesUC = getArticlesUseCase(repo);
|
||||||
|
const getBySlugUC = getArticleBySlugUseCase(repo);
|
||||||
|
const createCtrl = createArticleController(createUseCase);
|
||||||
|
const listCtrl = getArticlesController(getArticlesUC);
|
||||||
|
const bySlugCtrl = getArticleBySlugController(getBySlugUC);
|
||||||
|
return { createCtrl, listCtrl, bySlugCtrl };
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
it("creates an article via controller, then fetches it back by slug", async () => {
|
||||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
const { createCtrl, bySlugCtrl } = buildChain();
|
||||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
|
||||||
}
|
|
||||||
repo = new MockArticlesRepository();
|
|
||||||
blogContainer
|
|
||||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
|
||||||
.toConstantValue(repo);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates an article via tRPC, then fetches it back by slug", async () => {
|
const created = await createCtrl({
|
||||||
const caller = blogRouter.createCaller({});
|
|
||||||
|
|
||||||
const created = await caller.createArticle({
|
|
||||||
title: "The Vertical Refactor",
|
title: "The Vertical Refactor",
|
||||||
content: { type: "doc", children: [] },
|
content: { type: "doc", children: [] },
|
||||||
authorId: "u1",
|
authorId: "u1",
|
||||||
@@ -35,22 +36,28 @@ describe("blog feature: article-by-slug end-to-end", () => {
|
|||||||
expect(created.id).toBeTruthy();
|
expect(created.id).toBeTruthy();
|
||||||
expect(created.slug).toBe("vertical-refactor");
|
expect(created.slug).toBe("vertical-refactor");
|
||||||
|
|
||||||
const fetched = await caller.articleBySlug({ slug: "vertical-refactor" });
|
const fetched = await bySlugCtrl({ slug: "vertical-refactor" });
|
||||||
expect(fetched?.id).toBe(created.id);
|
expect(fetched.id).toBe(created.id);
|
||||||
expect(fetched?.title).toBe("The Vertical Refactor");
|
expect(fetched.title).toBe("The Vertical Refactor");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("listArticles filters by status", async () => {
|
it("listArticles filters by status", async () => {
|
||||||
const caller = blogRouter.createCaller({});
|
const { createCtrl, listCtrl } = buildChain();
|
||||||
await caller.createArticle({
|
|
||||||
|
await createCtrl({
|
||||||
title: "Draft One",
|
title: "Draft One",
|
||||||
content: null,
|
content: null,
|
||||||
authorId: "u1",
|
authorId: "u1",
|
||||||
});
|
});
|
||||||
const draftOnly = await caller.listArticles({ status: "draft" });
|
const draftOnly = await listCtrl({ status: "draft" });
|
||||||
expect(draftOnly).toHaveLength(1);
|
expect(draftOnly).toHaveLength(1);
|
||||||
|
|
||||||
const publishedOnly = await caller.listArticles({ status: "published" });
|
const publishedOnly = await listCtrl({ status: "published" });
|
||||||
expect(publishedOnly).toHaveLength(0);
|
expect(publishedOnly).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getArticleBySlugController throws ArticleNotFoundError for missing article", async () => {
|
||||||
|
const { bySlugCtrl } = buildChain();
|
||||||
|
await expect(bySlugCtrl({ slug: "missing-slug" })).rejects.toBeInstanceOf(ArticleNotFoundError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user