refactor(blog): unify use-case I/O schemas + presenter + feature error map

Per Plan 9 (spec R1-R28):
- Use cases: input + output schemas (getArticles, createArticle,
  getArticleBySlug). Output validated via outputSchema.parse before
  return. status field uses articleStatusSchema (was loose `string`).
- Controllers: receive `unknown`; safeParse with use-case schema;
  identity presenter (R11) on every controller.
- New integrations/api/procedures.ts with blogProcedure
  ([InputParseError → BAD_REQUEST], [ArticleNotFoundError → NOT_FOUND]).
- Router uses blogProcedure + .input(xInputSchema) for all 3 procedures.
- src/index.ts: remove articleBySlugQuery/listArticlesQuery re-exports;
  export schemas + types + IUseCase/IController aliases.
- src/ui/index.ts (NEW): query builders moved here; package.json adds
  ./ui subpath.
- New tests: R25 output-validation per use case; R26 router error-
  mapping (NOT_FOUND on missing slug, BAD_REQUEST on schema fail).

Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2
Spec: R1–R6, R8–R15, R18–R20, R22–R26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:04:53 +02:00
parent 70c7b7dfe7
commit 86070b236a
18 changed files with 285 additions and 100 deletions

View File

@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { createArticleUseCase } from "@/application/use-cases/create-article.use-case";
import { ZodError } from "zod";
import { createArticleUseCase, createArticleOutputSchema } from "@/application/use-cases/create-article.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
describe("createArticleUseCase", () => {
it("creates an article in draft status with auto-generated slug", async () => {
@@ -34,3 +36,30 @@ describe("createArticleUseCase", () => {
expect(result.slug).toBe("custom-slug");
});
});
describe("createArticleUseCase output validation (R25)", () => {
it("throws when repository returns a malformed article", async () => {
const repo = {
createArticle: async () => ({ id: 1 }) as unknown as never,
} as unknown as IArticlesRepository;
const useCase = createArticleUseCase(repo);
await expect(
useCase({ title: "X", authorId: "u1" }),
).rejects.toBeInstanceOf(ZodError);
});
it("exports an output schema that accepts a valid article shape", () => {
expect(createArticleOutputSchema).toBeDefined();
const result = createArticleOutputSchema.safeParse({
id: "a1",
title: "Test",
slug: "test",
content: null,
status: "draft",
authorId: "u1",
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.success).toBe(true);
});
});

View File

@@ -1,6 +1,21 @@
import type { Article } from "../../entities/models/article";
import { z } from "zod";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
export const createArticleInputSchema = z
.object({
title: z.string().min(1).max(255),
content: z.unknown().optional(),
authorId: z.string(),
slug: z.string().optional(),
})
.strict();
export type CreateArticleInput = z.infer<typeof createArticleInputSchema>;
export const createArticleOutputSchema = articleSchema;
export type CreateArticleOutput = z.infer<typeof createArticleOutputSchema>;
function generateSlug(title: string): string {
return title
.toLowerCase()
@@ -12,23 +27,18 @@ export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
export const createArticleUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: {
title: string;
content?: unknown;
authorId: string;
slug?: string;
}): Promise<Article> => {
async (input: CreateArticleInput): Promise<CreateArticleOutput> => {
const now = new Date();
const article: Article = {
const article = {
id: crypto.randomUUID(),
title: input.title,
slug: input.slug ?? generateSlug(input.title),
content: input.content,
status: "draft",
content: input.content ?? null,
status: "draft" as const,
authorId: input.authorId,
createdAt: now,
updatedAt: now,
};
return articlesRepository.createArticle(article);
const result = await articlesRepository.createArticle(article);
return createArticleOutputSchema.parse(result);
};

View File

@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case";
import { ZodError } from "zod";
import { getArticleBySlugUseCase, getArticleBySlugOutputSchema } 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";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
describe("getArticleBySlugUseCase", () => {
it("returns the article when slug exists", async () => {
@@ -22,3 +24,28 @@ describe("getArticleBySlugUseCase", () => {
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(ArticleNotFoundError);
});
});
describe("getArticleBySlugUseCase output validation (R25)", () => {
it("throws when repository returns a malformed article", async () => {
const repo = {
getArticleBySlug: async () => ({ id: 123 }) as unknown as never,
} as unknown as IArticlesRepository;
const useCase = getArticleBySlugUseCase(repo);
await expect(useCase({ slug: "test" })).rejects.toBeInstanceOf(ZodError);
});
it("exports an output schema that accepts a valid article shape", () => {
expect(getArticleBySlugOutputSchema).toBeDefined();
const result = getArticleBySlugOutputSchema.safeParse({
id: "a1",
title: "Test",
slug: "test",
content: null,
status: "draft",
authorId: "u1",
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.success).toBe(true);
});
});

View File

@@ -1,15 +1,25 @@
import { z } from "zod";
import { ArticleNotFoundError } from "../../entities/errors/article";
import type { Article } from "../../entities/models/article";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
export const getArticleBySlugInputSchema = z
.object({ slug: z.string().min(1) })
.strict();
export type GetArticleBySlugInput = z.infer<typeof getArticleBySlugInputSchema>;
export const getArticleBySlugOutputSchema = articleSchema;
export type GetArticleBySlugOutput = z.infer<typeof getArticleBySlugOutputSchema>;
export type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;
export const getArticleBySlugUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: { slug: string }): Promise<Article> => {
async (input: GetArticleBySlugInput): Promise<GetArticleBySlugOutput> => {
const article = await articlesRepository.getArticleBySlug(input.slug);
if (!article) {
throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`);
}
return article;
return getArticleBySlugOutputSchema.parse(article);
};

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
import { ZodError } from "zod";
import { getArticlesUseCase, getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { articleFactory } from "@/__factories__/article.factory";
@@ -10,7 +11,7 @@ describe("getArticlesUseCase", () => {
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
const useCase = getArticlesUseCase(repo);
const result = await useCase();
const result = await useCase({});
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("1");
});
@@ -27,3 +28,19 @@ describe("getArticlesUseCase", () => {
expect(result[0]?.id).toBe("2");
});
});
describe("getArticlesUseCase output validation (R25)", () => {
it("throws when the repository returns a malformed article", async () => {
const repo = new MockArticlesRepository();
// bypass the mock's createArticle (which is typed) by reaching into _articles directly
(repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 });
const useCase = getArticlesUseCase(repo);
await expect(useCase({})).rejects.toBeInstanceOf(ZodError);
});
it("exports an output schema that mirrors Article[]", () => {
expect(getArticlesOutputSchema).toBeDefined();
expect(getArticlesOutputSchema.safeParse([]).success).toBe(true);
});
});

View File

@@ -1,15 +1,26 @@
import type { Article } from "../../entities/models/article";
import { z } from "zod";
import { articleSchema, articleStatusSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
export const getArticlesInputSchema = z
.object({
status: articleStatusSchema.optional(),
authorId: z.string().optional(),
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
})
.strict();
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
export const getArticlesOutputSchema = z.array(articleSchema);
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
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);
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
const result = await articlesRepository.getArticles(input);
return getArticlesOutputSchema.parse(result);
};

View File

@@ -2,4 +2,31 @@ export type { Article, ArticleStatus } from "./entities/models/article";
export type { BlogRouter } from "./integrations/api/router";
export { ArticleNotFoundError } from "./entities/errors/article";
export { InputParseError } from "./entities/errors/common";
export { articleBySlugQuery, listArticlesQuery } from "./ui/query";
// Use case schemas + types (Plan 9 R18)
export {
getArticlesInputSchema,
getArticlesOutputSchema,
type GetArticlesInput,
type GetArticlesOutput,
type IGetArticlesUseCase,
} from "./application/use-cases/get-articles.use-case";
export {
createArticleInputSchema,
createArticleOutputSchema,
type CreateArticleInput,
type CreateArticleOutput,
type ICreateArticleUseCase,
} from "./application/use-cases/create-article.use-case";
export {
getArticleBySlugInputSchema,
getArticleBySlugOutputSchema,
type GetArticleBySlugInput,
type GetArticleBySlugOutput,
type IGetArticleBySlugUseCase,
} from "./application/use-cases/get-article-by-slug.use-case";
// Controller type aliases
export type { IGetArticlesController } from "./interface-adapters/controllers/get-articles.controller";
export type { ICreateArticleController } from "./interface-adapters/controllers/create-article.controller";
export type { IGetArticleBySlugController } from "./interface-adapters/controllers/get-article-by-slug.controller";

View File

@@ -0,0 +1,12 @@
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { InputParseError } from "../../entities/errors/common";
export const blogProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[ArticleNotFoundError, "NOT_FOUND"],
]),
);

View File

@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "../../di/container";
import { BlogModule } from "../../di/module";
import { blogRouter } from "./router";
import { TRPCError } from "@trpc/server";
import { blogContainer } from "@/di/container";
import { BlogModule } from "@/di/module";
import { blogRouter } from "@/integrations/api/router";
// The router resolves controllers from blogContainer (a singleton).
// We reload the module between tests to get a fresh MockArticlesRepository.
@@ -24,7 +25,7 @@ describe("blogRouter", () => {
it("listArticles returns empty array by default", async () => {
const caller = blogRouter.createCaller({});
const result = await caller.listArticles();
const result = await caller.listArticles({});
expect(result).toEqual([]);
});
@@ -43,3 +44,36 @@ describe("blogRouter", () => {
expect(fetched.id).toBe(created.id);
});
});
describe("blogRouter (R26 error mapping)", () => {
beforeEach(() => {
blogContainer.unbindAll();
blogContainer.load(BlogModule);
});
afterEach(() => {
blogContainer.unbindAll();
});
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
const caller = blogRouter.createCaller({});
try {
await caller.articleBySlug({ slug: "missing" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("NOT_FOUND");
}
});
it("translates zod parse failure → BAD_REQUEST", async () => {
const caller = blogRouter.createCaller({});
try {
await caller.articleBySlug({} as unknown as { slug: string });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
});

View File

@@ -1,14 +1,21 @@
import { z } from "zod";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import { router } from "@repo/core-shared/trpc/init";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import { getArticlesInputSchema } from "../../application/use-cases/get-articles.use-case";
import { createArticleInputSchema } from "../../application/use-cases/create-article.use-case";
import { getArticleBySlugInputSchema } from "../../application/use-cases/get-article-by-slug.use-case";
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";
import { blogProcedure } from "./procedures";
export const blogRouter = router({
articleBySlug: publicProcedure
.input(z.object({ slug: z.string().min(1) }))
articleBySlug: blogProcedure
.input(getArticleBySlugInputSchema)
.query(({ input }) => {
const ctrl = blogContainer.get<IGetArticleBySlugController>(
BLOG_SYMBOLS.IGetArticleBySlugController,
@@ -16,33 +23,17 @@ export const blogRouter = router({
return ctrl(input);
}),
listArticles: publicProcedure
.input(
z
.object({
status: z.string().optional(),
authorId: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
.optional(),
)
listArticles: blogProcedure
.input(getArticlesInputSchema)
.query(({ input }) => {
const ctrl = blogContainer.get<IGetArticlesController>(
BLOG_SYMBOLS.IGetArticlesController,
);
return ctrl(input ?? {});
return ctrl(input);
}),
createArticle: publicProcedure
.input(
z.object({
title: z.string().min(1).max(255),
content: z.unknown().optional(),
authorId: z.string(),
slug: z.string().optional(),
}),
)
createArticle: blogProcedure
.input(createArticleInputSchema)
.mutation(({ input }) => {
const ctrl = blogContainer.get<ICreateArticleController>(
BLOG_SYMBOLS.ICreateArticleController,

View File

@@ -1,29 +1,23 @@
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";
import {
createArticleInputSchema,
type CreateArticleOutput,
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(),
});
function presenter(value: CreateArticleOutput) {
return value;
}
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);
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = createArticleInputSchema.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,
});
const result = await createArticleUseCase(parsed.data);
return presenter(result);
};

View File

@@ -33,7 +33,7 @@ describe("getArticleBySlugController", () => {
const controller = getArticleBySlugController(useCase);
await expect(
controller({} as { slug: string }),
controller({}),
).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -1,21 +1,23 @@
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";
import {
getArticleBySlugInputSchema,
type GetArticleBySlugOutput,
type IGetArticleBySlugUseCase,
} from "../../application/use-cases/get-article-by-slug.use-case";
const inputSchema = z.object({
slug: z.string().min(1),
});
function presenter(value: GetArticleBySlugOutput) {
return value;
}
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);
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticleBySlugInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
}
return getArticleBySlugUseCase(parsed.data);
const result = await getArticleBySlugUseCase(parsed.data);
return presenter(result);
};

View File

@@ -35,7 +35,7 @@ describe("getArticlesController", () => {
const controller = getArticlesController(useCase);
await expect(
controller({ limit: "not a number" } as unknown as Record<string, unknown>),
controller({ limit: "not a number" }),
).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -1,24 +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";
import {
getArticlesInputSchema,
type GetArticlesOutput,
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(),
});
function presenter(value: GetArticlesOutput) {
// identity for now (R11 — every non-void controller has a presenter)
return value;
}
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);
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticlesInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-articles input", { cause: parsed.error });
}
return getArticlesUseCase(parsed.data);
const result = await getArticlesUseCase(parsed.data);
return presenter(result);
};

View File

@@ -0,0 +1 @@
export { articleBySlugQuery, listArticlesQuery } from "./query";