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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user