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:
@@ -19,6 +19,8 @@ doc-update items so docs are written once for the post-Plan-9 state.
|
|||||||
- packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough (verifies INTERNAL_SERVER_ERROR + cause preservation), cause preservation
|
- packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough (verifies INTERNAL_SERVER_ERROR + cause preservation), cause preservation
|
||||||
- packages/auth/src/integrations/api/procedures.ts — authProcedure with feature error map (InputParse → BAD_REQUEST, Auth/Unauthenticated → UNAUTHORIZED, Unauthorized → FORBIDDEN)
|
- packages/auth/src/integrations/api/procedures.ts — authProcedure with feature error map (InputParse → BAD_REQUEST, Auth/Unauthenticated → UNAUTHORIZED, Unauthorized → FORBIDDEN)
|
||||||
- packages/auth/src/ui/index.ts — placeholder UI surface (no queries today; mutations only)
|
- packages/auth/src/ui/index.ts — placeholder UI surface (no queries today; mutations only)
|
||||||
|
- packages/blog/src/integrations/api/procedures.ts — blogProcedure with feature error map (InputParseError → BAD_REQUEST, ArticleNotFoundError → NOT_FOUND)
|
||||||
|
- packages/blog/src/ui/index.ts — re-exports articleBySlugQuery and listArticlesQuery from ./query (moved from feature root index)
|
||||||
|
|
||||||
## 2. Files modified
|
## 2. Files modified
|
||||||
|
|
||||||
@@ -36,17 +38,30 @@ doc-update items so docs are written once for the post-Plan-9 state.
|
|||||||
- packages/auth/package.json — added ./ui subpath
|
- packages/auth/package.json — added ./ui subpath
|
||||||
- packages/auth/tests/sign-in-flow.feature.test.ts — updated to match new presenter shapes (cookie return, void sign-out, object input)
|
- packages/auth/tests/sign-in-flow.feature.test.ts — updated to match new presenter shapes (cookie return, void sign-out, object input)
|
||||||
- All affected auth use-case + controller tests updated for new contracts
|
- All affected auth use-case + controller tests updated for new contracts
|
||||||
|
- packages/blog/src/application/use-cases/get-articles.use-case.ts — getArticlesInputSchema + getArticlesOutputSchema; status narrowed to articleStatusSchema; output.parse; types exported
|
||||||
|
- packages/blog/src/application/use-cases/create-article.use-case.ts — createArticleInputSchema + createArticleOutputSchema; output.parse; types exported
|
||||||
|
- packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts — getArticleBySlugInputSchema + getArticleBySlugOutputSchema; output.parse; types exported
|
||||||
|
- packages/blog/src/interface-adapters/controllers/get-articles.controller.ts — identity presenter; unknown input; imports getArticlesInputSchema from use-case
|
||||||
|
- packages/blog/src/interface-adapters/controllers/create-article.controller.ts — identity presenter; unknown input; imports createArticleInputSchema from use-case
|
||||||
|
- packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts — identity presenter; unknown input; imports getArticleBySlugInputSchema from use-case
|
||||||
|
- packages/blog/src/integrations/api/router.ts — uses blogProcedure + .input(xInputSchema) for all 3 procedures
|
||||||
|
- packages/blog/src/index.ts — removed articleBySlugQuery/listArticlesQuery re-exports; added schemas + types + IUseCase/IController aliases
|
||||||
|
- packages/blog/package.json — added ./ui subpath export
|
||||||
|
- All affected blog use-case + controller tests updated for new contracts
|
||||||
|
|
||||||
## 3. Pattern changes (code-level)
|
## 3. Pattern changes (code-level)
|
||||||
|
|
||||||
### 3.1 Use-case files — input + output schemas + runtime parse
|
### 3.1 Use-case files — input + output schemas + runtime parse
|
||||||
auth migrated: all 3 use cases. signIn and signUp export xInputSchema + xOutputSchema + types; signOut exports xInputSchema only (void output). All non-void use cases end with `xOutputSchema.parse(result)` before returning.
|
auth migrated: all 3 use cases. signIn and signUp export xInputSchema + xOutputSchema + types; signOut exports xInputSchema only (void output). All non-void use cases end with `xOutputSchema.parse(result)` before returning.
|
||||||
|
blog migrated: all 3 use cases. getArticles, createArticle, getArticleBySlug each export xInputSchema + xOutputSchema + XInput + XOutput types. getArticlesInputSchema narrows status to articleStatusSchema (not loose string). All 3 end with `xOutputSchema.parse(result)` before returning.
|
||||||
|
|
||||||
### 3.2 Controller files — presenter + unknown input + view return type
|
### 3.2 Controller files — presenter + unknown input + view return type
|
||||||
auth migrated: all 3 controllers. signIn/signUp have `function presenter(value: XOutput)` returning `value.cookie`; return type is `ReturnType<typeof presenter>`. signOut has no presenter (void). All controllers accept `unknown` input and safeparse with the use-case schema.
|
auth migrated: all 3 controllers. signIn/signUp have `function presenter(value: XOutput)` returning `value.cookie`; return type is `ReturnType<typeof presenter>`. signOut has no presenter (void). All controllers accept `unknown` input and safeparse with the use-case schema.
|
||||||
|
blog migrated: all 3 controllers. All 3 (getArticles, createArticle, getArticleBySlug) have `function presenter(value: XOutput)` that is identity (`return value`); return type is `ReturnType<typeof presenter>`. All accept `unknown` input and safeparse with the imported use-case schema.
|
||||||
|
|
||||||
### 3.3 tRPC integration — feature-scoped procedures, schema reuse from use cases
|
### 3.3 tRPC integration — feature-scoped procedures, schema reuse from use cases
|
||||||
auth migrated: authProcedure in procedures.ts wraps defineErrorMiddleware with 4-tuple error map. Router uses `authProcedure.input(xInputSchema)` for all 3 procedures — no more local schema redefinition.
|
auth migrated: authProcedure in procedures.ts wraps defineErrorMiddleware with 4-tuple error map. Router uses `authProcedure.input(xInputSchema)` for all 3 procedures — no more local schema redefinition.
|
||||||
|
blog migrated: blogProcedure in procedures.ts wraps defineErrorMiddleware with 2-tuple map (InputParseError → BAD_REQUEST, ArticleNotFoundError → NOT_FOUND). Router uses `blogProcedure.input(xInputSchema)` for all 3 procedures.
|
||||||
|
|
||||||
## 4. Error-middleware adoption
|
## 4. Error-middleware adoption
|
||||||
|
|
||||||
@@ -59,17 +74,21 @@ auth migrated: authProcedure in procedures.ts wraps defineErrorMiddleware with 4
|
|||||||
|
|
||||||
### 5.1 ./ui subpath added per feature
|
### 5.1 ./ui subpath added per feature
|
||||||
auth: `./ui` subpath added to package.json exports; `src/ui/index.ts` placeholder created (auth has no query builders — all procedures are mutations).
|
auth: `./ui` subpath added to package.json exports; `src/ui/index.ts` placeholder created (auth has no query builders — all procedures are mutations).
|
||||||
|
blog: `./ui` subpath added to package.json exports; `src/ui/index.ts` created re-exporting articleBySlugQuery and listArticlesQuery from ./query.
|
||||||
|
|
||||||
### 5.2 Feature root index.ts cleanup
|
### 5.2 Feature root index.ts cleanup
|
||||||
auth: root `src/index.ts` now exports all use-case schemas (signInInputSchema, signInOutputSchema, signUpInputSchema, signUpOutputSchema, signOutInputSchema) and types (SignInInput/Output, SignUpInput/Output, SignOutInput, ISignInUseCase, ISignUpUseCase, ISignOutUseCase) plus controller type aliases.
|
auth: root `src/index.ts` now exports all use-case schemas (signInInputSchema, signInOutputSchema, signUpInputSchema, signUpOutputSchema, signOutInputSchema) and types (SignInInput/Output, SignUpInput/Output, SignOutInput, ISignInUseCase, ISignUpUseCase, ISignOutUseCase) plus controller type aliases.
|
||||||
|
blog: root `src/index.ts` removed articleBySlugQuery/listArticlesQuery re-exports (moved to ./ui); now exports getArticlesInputSchema/Output, createArticleInputSchema/Output, getArticleBySlugInputSchema/Output, all XInput/XOutput types, IUseCase aliases, and IController aliases.
|
||||||
|
|
||||||
## 6. Test additions
|
## 6. Test additions
|
||||||
|
|
||||||
### 6.1 R25 — output-validation tests (use case)
|
### 6.1 R25 — output-validation tests (use case)
|
||||||
auth: signIn and signUp each have 2 new R25 tests — one verifying that a malformed service response throws (Zod parse error), one verifying the output schema parses a valid shape. signOut is void — no R25 test.
|
auth: signIn and signUp each have 2 new R25 tests — one verifying that a malformed service response throws (Zod parse error), one verifying the output schema parses a valid shape. signOut is void — no R25 test.
|
||||||
|
blog: getArticles, createArticle, getArticleBySlug each have 2 new R25 tests — one verifying that a repository returning a malformed object throws ZodError (instanceof), one verifying the output schema parses a valid shape.
|
||||||
|
|
||||||
### 6.2 R26 — router error-mapping tests
|
### 6.2 R26 — router error-mapping tests
|
||||||
auth: 2 new R26 tests in router.test.ts — UNAUTHORIZED on missing user (AuthenticationError translation), BAD_REQUEST on Zod schema failure (schema validation at procedure boundary).
|
auth: 2 new R26 tests in router.test.ts — UNAUTHORIZED on missing user (AuthenticationError translation), BAD_REQUEST on Zod schema failure (schema validation at procedure boundary).
|
||||||
|
blog: 2 new R26 tests in router.test.ts — NOT_FOUND on articleBySlug with missing slug (ArticleNotFoundError translation via blogProcedure), BAD_REQUEST on articleBySlug with empty input ({} as { slug: string }) (schema validation at tRPC procedure boundary).
|
||||||
|
|
||||||
### 6.3 R27/R28 — presenter shape tests
|
### 6.3 R27/R28 — presenter shape tests
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./ui": "./src/ui/index.ts",
|
||||||
"./cms": "./src/integrations/cms/index.ts",
|
"./cms": "./src/integrations/cms/index.ts",
|
||||||
"./api": "./src/integrations/api/router.ts",
|
"./api": "./src/integrations/api/router.ts",
|
||||||
"./di/bind-production": "./src/di/bind-production.ts"
|
"./di/bind-production": "./src/di/bind-production.ts"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
|
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||||
|
|
||||||
describe("createArticleUseCase", () => {
|
describe("createArticleUseCase", () => {
|
||||||
it("creates an article in draft status with auto-generated slug", async () => {
|
it("creates an article in draft status with auto-generated slug", async () => {
|
||||||
@@ -34,3 +36,30 @@ describe("createArticleUseCase", () => {
|
|||||||
expect(result.slug).toBe("custom-slug");
|
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";
|
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 {
|
function generateSlug(title: string): string {
|
||||||
return title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -12,23 +27,18 @@ export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;
|
|||||||
|
|
||||||
export const createArticleUseCase =
|
export const createArticleUseCase =
|
||||||
(articlesRepository: IArticlesRepository) =>
|
(articlesRepository: IArticlesRepository) =>
|
||||||
async (input: {
|
async (input: CreateArticleInput): Promise<CreateArticleOutput> => {
|
||||||
title: string;
|
|
||||||
content?: unknown;
|
|
||||||
authorId: string;
|
|
||||||
slug?: string;
|
|
||||||
}): Promise<Article> => {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const article: Article = {
|
const article = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: input.title,
|
title: input.title,
|
||||||
slug: input.slug ?? generateSlug(input.title),
|
slug: input.slug ?? generateSlug(input.title),
|
||||||
content: input.content,
|
content: input.content ?? null,
|
||||||
status: "draft",
|
status: "draft" as const,
|
||||||
authorId: input.authorId,
|
authorId: input.authorId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
const result = await articlesRepository.createArticle(article);
|
||||||
return articlesRepository.createArticle(article);
|
return createArticleOutputSchema.parse(result);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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 { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import { ArticleNotFoundError } from "@/entities/errors/article";
|
import { ArticleNotFoundError } from "@/entities/errors/article";
|
||||||
import { articleFactory } from "@/__factories__/article.factory";
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
|
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||||
|
|
||||||
describe("getArticleBySlugUseCase", () => {
|
describe("getArticleBySlugUseCase", () => {
|
||||||
it("returns the article when slug exists", async () => {
|
it("returns the article when slug exists", async () => {
|
||||||
@@ -22,3 +24,28 @@ describe("getArticleBySlugUseCase", () => {
|
|||||||
await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(ArticleNotFoundError);
|
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 { 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";
|
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 type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;
|
||||||
|
|
||||||
export const getArticleBySlugUseCase =
|
export const getArticleBySlugUseCase =
|
||||||
(articlesRepository: IArticlesRepository) =>
|
(articlesRepository: IArticlesRepository) =>
|
||||||
async (input: { slug: string }): Promise<Article> => {
|
async (input: GetArticleBySlugInput): Promise<GetArticleBySlugOutput> => {
|
||||||
const article = await articlesRepository.getArticleBySlug(input.slug);
|
const article = await articlesRepository.getArticleBySlug(input.slug);
|
||||||
if (!article) {
|
if (!article) {
|
||||||
throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`);
|
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 { 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 { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import { articleFactory } from "@/__factories__/article.factory";
|
import { articleFactory } from "@/__factories__/article.factory";
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ describe("getArticlesUseCase", () => {
|
|||||||
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
|
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
|
||||||
|
|
||||||
const useCase = getArticlesUseCase(repo);
|
const useCase = getArticlesUseCase(repo);
|
||||||
const result = await useCase();
|
const result = await useCase({});
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]?.id).toBe("1");
|
expect(result[0]?.id).toBe("1");
|
||||||
});
|
});
|
||||||
@@ -27,3 +28,19 @@ describe("getArticlesUseCase", () => {
|
|||||||
expect(result[0]?.id).toBe("2");
|
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";
|
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 type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||||||
|
|
||||||
export const getArticlesUseCase =
|
export const getArticlesUseCase =
|
||||||
(articlesRepository: IArticlesRepository) =>
|
(articlesRepository: IArticlesRepository) =>
|
||||||
async (options?: {
|
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
|
||||||
status?: string;
|
const result = await articlesRepository.getArticles(input);
|
||||||
authorId?: string;
|
return getArticlesOutputSchema.parse(result);
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<Article[]> => {
|
|
||||||
return articlesRepository.getArticles(options);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,4 +2,31 @@ export type { Article, ArticleStatus } from "./entities/models/article";
|
|||||||
export type { BlogRouter } from "./integrations/api/router";
|
export type { BlogRouter } from "./integrations/api/router";
|
||||||
export { ArticleNotFoundError } from "./entities/errors/article";
|
export { ArticleNotFoundError } from "./entities/errors/article";
|
||||||
export { InputParseError } from "./entities/errors/common";
|
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";
|
||||||
|
|||||||
12
packages/blog/src/integrations/api/procedures.ts
Normal file
12
packages/blog/src/integrations/api/procedures.ts
Normal 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"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { blogContainer } from "../../di/container";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { BlogModule } from "../../di/module";
|
import { blogContainer } from "@/di/container";
|
||||||
import { blogRouter } from "./router";
|
import { BlogModule } from "@/di/module";
|
||||||
|
import { blogRouter } from "@/integrations/api/router";
|
||||||
|
|
||||||
// The router resolves controllers from blogContainer (a singleton).
|
// The router resolves controllers from blogContainer (a singleton).
|
||||||
// We reload the module between tests to get a fresh MockArticlesRepository.
|
// 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 () => {
|
it("listArticles returns empty array by default", 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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,3 +44,36 @@ describe("blogRouter", () => {
|
|||||||
expect(fetched.id).toBe(created.id);
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { z } from "zod";
|
import { router } from "@repo/core-shared/trpc/init";
|
||||||
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
|
|
||||||
import { blogContainer } from "../../di/container";
|
import { blogContainer } from "../../di/container";
|
||||||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
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 { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
|
||||||
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller";
|
||||||
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
|
||||||
|
|
||||||
|
import { blogProcedure } from "./procedures";
|
||||||
|
|
||||||
export const blogRouter = router({
|
export const blogRouter = router({
|
||||||
articleBySlug: publicProcedure
|
articleBySlug: blogProcedure
|
||||||
.input(z.object({ slug: z.string().min(1) }))
|
.input(getArticleBySlugInputSchema)
|
||||||
.query(({ input }) => {
|
.query(({ input }) => {
|
||||||
const ctrl = blogContainer.get<IGetArticleBySlugController>(
|
const ctrl = blogContainer.get<IGetArticleBySlugController>(
|
||||||
BLOG_SYMBOLS.IGetArticleBySlugController,
|
BLOG_SYMBOLS.IGetArticleBySlugController,
|
||||||
@@ -16,33 +23,17 @@ export const blogRouter = router({
|
|||||||
return ctrl(input);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listArticles: publicProcedure
|
listArticles: blogProcedure
|
||||||
.input(
|
.input(getArticlesInputSchema)
|
||||||
z
|
|
||||||
.object({
|
|
||||||
status: z.string().optional(),
|
|
||||||
authorId: z.string().optional(),
|
|
||||||
limit: z.number().optional(),
|
|
||||||
offset: z.number().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
)
|
|
||||||
.query(({ input }) => {
|
.query(({ input }) => {
|
||||||
const ctrl = blogContainer.get<IGetArticlesController>(
|
const ctrl = blogContainer.get<IGetArticlesController>(
|
||||||
BLOG_SYMBOLS.IGetArticlesController,
|
BLOG_SYMBOLS.IGetArticlesController,
|
||||||
);
|
);
|
||||||
return ctrl(input ?? {});
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createArticle: publicProcedure
|
createArticle: blogProcedure
|
||||||
.input(
|
.input(createArticleInputSchema)
|
||||||
z.object({
|
|
||||||
title: z.string().min(1).max(255),
|
|
||||||
content: z.unknown().optional(),
|
|
||||||
authorId: z.string(),
|
|
||||||
slug: z.string().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(({ input }) => {
|
.mutation(({ input }) => {
|
||||||
const ctrl = blogContainer.get<ICreateArticleController>(
|
const ctrl = blogContainer.get<ICreateArticleController>(
|
||||||
BLOG_SYMBOLS.ICreateArticleController,
|
BLOG_SYMBOLS.ICreateArticleController,
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
import type { Article } from "../../entities/models/article";
|
import {
|
||||||
import type { ICreateArticleUseCase } from "../../application/use-cases/create-article.use-case";
|
createArticleInputSchema,
|
||||||
|
type CreateArticleOutput,
|
||||||
|
type ICreateArticleUseCase,
|
||||||
|
} from "../../application/use-cases/create-article.use-case";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
function presenter(value: CreateArticleOutput) {
|
||||||
title: z.string().min(1).max(255),
|
return value;
|
||||||
content: z.unknown().optional(),
|
}
|
||||||
authorId: z.string(),
|
|
||||||
slug: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ICreateArticleController = ReturnType<typeof createArticleController>;
|
export type ICreateArticleController = ReturnType<typeof createArticleController>;
|
||||||
|
|
||||||
export const createArticleController =
|
export const createArticleController =
|
||||||
(createArticleUseCase: ICreateArticleUseCase) =>
|
(createArticleUseCase: ICreateArticleUseCase) =>
|
||||||
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Article> => {
|
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = inputSchema.safeParse(input);
|
const parsed = createArticleInputSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new InputParseError("Invalid create-article input", { cause: parsed.error });
|
throw new InputParseError("Invalid create-article input", { cause: parsed.error });
|
||||||
}
|
}
|
||||||
return createArticleUseCase({
|
const result = await createArticleUseCase(parsed.data);
|
||||||
title: parsed.data.title,
|
return presenter(result);
|
||||||
content: parsed.data.content ?? null,
|
|
||||||
authorId: parsed.data.authorId,
|
|
||||||
slug: parsed.data.slug,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe("getArticleBySlugController", () => {
|
|||||||
const controller = getArticleBySlugController(useCase);
|
const controller = getArticleBySlugController(useCase);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller({} as { slug: string }),
|
controller({}),
|
||||||
).rejects.toBeInstanceOf(InputParseError);
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
import type { Article } from "../../entities/models/article";
|
import {
|
||||||
import type { IGetArticleBySlugUseCase } from "../../application/use-cases/get-article-by-slug.use-case";
|
getArticleBySlugInputSchema,
|
||||||
|
type GetArticleBySlugOutput,
|
||||||
|
type IGetArticleBySlugUseCase,
|
||||||
|
} from "../../application/use-cases/get-article-by-slug.use-case";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
function presenter(value: GetArticleBySlugOutput) {
|
||||||
slug: z.string().min(1),
|
return value;
|
||||||
});
|
}
|
||||||
|
|
||||||
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
|
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
|
||||||
|
|
||||||
export const getArticleBySlugController =
|
export const getArticleBySlugController =
|
||||||
(getArticleBySlugUseCase: IGetArticleBySlugUseCase) =>
|
(getArticleBySlugUseCase: IGetArticleBySlugUseCase) =>
|
||||||
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Article> => {
|
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = inputSchema.safeParse(input);
|
const parsed = getArticleBySlugInputSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
|
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("getArticlesController", () => {
|
|||||||
const controller = getArticlesController(useCase);
|
const controller = getArticlesController(useCase);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller({ limit: "not a number" } as unknown as Record<string, unknown>),
|
controller({ limit: "not a number" }),
|
||||||
).rejects.toBeInstanceOf(InputParseError);
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { InputParseError } from "../../entities/errors/common";
|
import { InputParseError } from "../../entities/errors/common";
|
||||||
import type { Article } from "../../entities/models/article";
|
import {
|
||||||
import type { IGetArticlesUseCase } from "../../application/use-cases/get-articles.use-case";
|
getArticlesInputSchema,
|
||||||
|
type GetArticlesOutput,
|
||||||
|
type IGetArticlesUseCase,
|
||||||
|
} from "../../application/use-cases/get-articles.use-case";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
function presenter(value: GetArticlesOutput) {
|
||||||
status: z.string().optional(),
|
// identity for now (R11 — every non-void controller has a presenter)
|
||||||
authorId: z.string().optional(),
|
return value;
|
||||||
limit: z.number().optional(),
|
}
|
||||||
offset: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
||||||
|
|
||||||
export const getArticlesController =
|
export const getArticlesController =
|
||||||
(getArticlesUseCase: IGetArticlesUseCase) =>
|
(getArticlesUseCase: IGetArticlesUseCase) =>
|
||||||
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Article[]> => {
|
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = inputSchema.safeParse(input);
|
const parsed = getArticlesInputSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new InputParseError("Invalid get-articles input", { cause: parsed.error });
|
throw new InputParseError("Invalid get-articles input", { cause: parsed.error });
|
||||||
}
|
}
|
||||||
return getArticlesUseCase(parsed.data);
|
const result = await getArticlesUseCase(parsed.data);
|
||||||
|
return presenter(result);
|
||||||
};
|
};
|
||||||
|
|||||||
1
packages/blog/src/ui/index.ts
Normal file
1
packages/blog/src/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { articleBySlugQuery, listArticlesQuery } from "./query";
|
||||||
Reference in New Issue
Block a user