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:
2026-05-06 00:08:15 +02:00
parent 780d5cb83b
commit 700d311052
21 changed files with 488 additions and 302 deletions

View File

@@ -1,25 +1,13 @@
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
import { MockArticlesRepository } from "../../infrastructure/repositories/articles.repository.mock";
import { createArticleUseCase } from "./create-article.use-case";
import { describe, expect, it } from "vitest";
import { createArticleUseCase } from "@/application/use-cases/create-article.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
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 () => {
const result = await createArticleUseCase({
const repo = new MockArticlesRepository();
const useCase = createArticleUseCase(repo);
const result = await useCase({
title: "Hello World",
content: "body",
authorId: "u1",
@@ -34,7 +22,10 @@ describe("createArticleUseCase", () => {
});
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",
content: "body",
authorId: "u1",

View File

@@ -1,6 +1,4 @@
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";
function generateSlug(title: string): string {
@@ -10,27 +8,27 @@ function generateSlug(title: string): string {
.replace(/^-+|-+$/g, "");
}
export async function createArticleUseCase(input: {
title: string;
content?: unknown;
authorId: string;
slug?: string;
}): Promise<Article> {
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
const now = new Date();
const article: Article = {
id: crypto.randomUUID(),
title: input.title,
slug: input.slug ?? generateSlug(input.title),
content: input.content,
status: "draft",
authorId: input.authorId,
createdAt: now,
updatedAt: now,
export const createArticleUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: {
title: string;
content?: unknown;
authorId: string;
slug?: string;
}): Promise<Article> => {
const now = new Date();
const article: Article = {
id: crypto.randomUUID(),
title: input.title,
slug: input.slug ?? generateSlug(input.title),
content: input.content,
status: "draft",
authorId: input.authorId,
createdAt: now,
updatedAt: now,
};
return articlesRepository.createArticle(article);
};
return repo.createArticle(article);
}

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -1,36 +1,28 @@
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
import { MockArticlesRepository } from "../../infrastructure/repositories/articles.repository.mock";
import { articleFactory } from "../../__factories__/article.factory";
import { getArticlesUseCase } from "./get-articles.use-case";
import { describe, expect, it } from "vitest";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { articleFactory } from "@/__factories__/article.factory";
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 () => {
const repo = new MockArticlesRepository();
articleFactory.reset();
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[0]?.id).toBe("1");
});
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: "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[0]?.id).toBe("2");
});

View File

@@ -1,16 +1,15 @@
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";
export async function getArticlesUseCase(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> {
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
return repo.getArticles(options);
}
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
export const getArticlesUseCase =
(articlesRepository: IArticlesRepository) =>
async (options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> => {
return articlesRepository.getArticles(options);
};

View File

@@ -22,15 +22,18 @@ describe("blogContainer", () => {
expect(repo).toBeInstanceOf(MockArticlesRepository);
});
it("supports rebinding to a custom repo", () => {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
const custom = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(custom);
const resolved = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
expect(resolved).toBe(custom);
it("resolves IGetArticlesController from the container", () => {
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticlesController);
expect(typeof ctrl).toBe("function");
});
it("resolves ICreateArticleController from the container", () => {
const ctrl = blogContainer.get(BLOG_SYMBOLS.ICreateArticleController);
expect(typeof ctrl).toBe("function");
});
it("resolves IGetArticleBySlugController from the container", () => {
const ctrl = blogContainer.get(BLOG_SYMBOLS.IGetArticleBySlugController);
expect(typeof ctrl).toBe("function");
});
});

View File

@@ -2,10 +2,68 @@ import { ContainerModule, type interfaces } from "inversify";
import type { IArticlesRepository } from "../application/repositories/articles.repository.interface";
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";
export const BlogModule = new ContainerModule((bind: interfaces.Bind) => {
bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(
MockArticlesRepository,
bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(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),
),
);
});

View File

@@ -1,3 +1,11 @@
export const BLOG_SYMBOLS = {
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;

View File

@@ -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 { BLOG_SYMBOLS } from "../../di/symbols";
import { MockArticlesRepository } from "../../infrastructure/repositories/articles.repository.mock";
import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
import { BlogModule } from "../../di/module";
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", () => {
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);
blogContainer.unbindAll();
blogContainer.load(BlogModule);
});
afterEach(() => {
blogContainer.unbindAll();
});
it("exposes articleBySlug, listArticles, createArticle procedures", () => {
@@ -26,19 +22,24 @@ describe("blogRouter", () => {
expect(procedureNames).toContain("createArticle");
});
it("articleBySlug returns the article when present", 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 () => {
it("listArticles returns empty array by default", async () => {
const caller = blogRouter.createCaller({});
const result = await caller.listArticles();
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);
});
});

View File

@@ -1,15 +1,20 @@
import { z } from "zod";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import {
createArticleController,
getArticlesController,
getArticleBySlugController,
} from "../../interface-adapters/controllers/articles.controller";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
export const blogRouter = router({
articleBySlug: publicProcedure
.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
.input(
@@ -22,7 +27,12 @@ export const blogRouter = router({
})
.optional(),
)
.query(({ input }) => getArticlesController(input ?? {})),
.query(({ input }) => {
const ctrl = blogContainer.get<IGetArticlesController>(
BLOG_SYMBOLS.IGetArticlesController,
);
return ctrl(input ?? {});
}),
createArticle: publicProcedure
.input(
@@ -33,7 +43,12 @@ export const blogRouter = router({
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;

View File

@@ -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);
});
});
});

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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,
});
};

View File

@@ -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);
});
});

View File

@@ -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);
};

View File

@@ -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);
});
});

View File

@@ -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);
};

View File

@@ -1,32 +1,33 @@
// Feature-level test: exercises the full slice
// tRPC procedure -> controller -> use-case -> mock repo
// without going through a network or the actual Payload Local API.
// Verifies that the layers are correctly wired through the per-feature DI container.
// use case factory chain → mock repo
// Tests the full dependency chain via direct injection (no container rebinding).
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "../src/di/container";
import { BLOG_SYMBOLS } from "../src/di/symbols";
import { describe, expect, it } from "vitest";
import { MockArticlesRepository } from "../src/infrastructure/repositories/articles.repository.mock";
import type { IArticlesRepository } from "../src/application/repositories/articles.repository.interface";
import { blogRouter } from "../src/integrations/api/router";
import { getArticlesUseCase } from "../src/application/use-cases/get-articles.use-case";
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", () => {
let repo: MockArticlesRepository;
describe("blog feature: article end-to-end via direct injection", () => {
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(() => {
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 via controller, then fetches it back by slug", async () => {
const { createCtrl, bySlugCtrl } = buildChain();
it("creates an article via tRPC, then fetches it back by slug", async () => {
const caller = blogRouter.createCaller({});
const created = await caller.createArticle({
const created = await createCtrl({
title: "The Vertical Refactor",
content: { type: "doc", children: [] },
authorId: "u1",
@@ -35,22 +36,28 @@ describe("blog feature: article-by-slug end-to-end", () => {
expect(created.id).toBeTruthy();
expect(created.slug).toBe("vertical-refactor");
const fetched = await caller.articleBySlug({ slug: "vertical-refactor" });
expect(fetched?.id).toBe(created.id);
expect(fetched?.title).toBe("The Vertical Refactor");
const fetched = await bySlugCtrl({ slug: "vertical-refactor" });
expect(fetched.id).toBe(created.id);
expect(fetched.title).toBe("The Vertical Refactor");
});
it("listArticles filters by status", async () => {
const caller = blogRouter.createCaller({});
await caller.createArticle({
const { createCtrl, listCtrl } = buildChain();
await createCtrl({
title: "Draft One",
content: null,
authorId: "u1",
});
const draftOnly = await caller.listArticles({ status: "draft" });
const draftOnly = await listCtrl({ status: "draft" });
expect(draftOnly).toHaveLength(1);
const publishedOnly = await caller.listArticles({ status: "published" });
const publishedOnly = await listCtrl({ status: "published" });
expect(publishedOnly).toHaveLength(0);
});
it("getArticleBySlugController throws ArticleNotFoundError for missing article", async () => {
const { bySlugCtrl } = buildChain();
await expect(bySlugCtrl({ slug: "missing-slug" })).rejects.toBeInstanceOf(ArticleNotFoundError);
});
});