diff --git a/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md b/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md index adf8329..12a2c37 100644 --- a/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md +++ b/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md @@ -94,6 +94,17 @@ Entity model moves (git mv — history preserved): - `packages/auth/src/infrastructure/services/authentication.service.ts` — real `AuthenticationService` using `node:crypto` for hashing/UUIDs; session methods deferred (see §7) - `packages/auth/src/infrastructure/services/authentication.service.test.ts` — tests for `generateUserId`, `hashPassword`/`verifyPassword` round-trip, and deferred-method error assertions +### Task 5: Blog factory refactor — new files + +- `packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts` — NEW use case factory; throws `ArticleNotFoundError` when slug is not found (previously the controller hit the repo directly, bypassing use-case error handling) +- `packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts` — 2 tests (slug found, slug missing → ArticleNotFoundError) +- `packages/blog/src/interface-adapters/controllers/get-articles.controller.ts` — factory controller, replaces the `getArticlesController` function from `articles.controller.ts` +- `packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts` — 3 tests (valid input, status filter, invalid shape → InputParseError) +- `packages/blog/src/interface-adapters/controllers/create-article.controller.ts` — factory controller, replaces `createArticleController` from `articles.controller.ts` +- `packages/blog/src/interface-adapters/controllers/create-article.controller.test.ts` — 3 tests (valid, missing title, missing authorId) +- `packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts` — factory controller; now delegates to `getArticleBySlugUseCase` (which throws `ArticleNotFoundError`) instead of calling repo directly +- `packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts` — 3 tests (found, not found → ArticleNotFoundError, empty slug → InputParseError) + ### Task 2: Entities split — new error files - `packages/auth/src/entities/errors/auth.ts` — AuthenticationError, UnauthenticatedError, UnauthorizedError (split from errors.ts) @@ -107,6 +118,11 @@ Entity model moves (git mv — history preserved): ## 3. Files deleted (with reason) +### Task 5: Blog factory refactor — deleted files + +- `packages/blog/src/interface-adapters/controllers/articles.controller.ts` — multi-method controller replaced by 3 single-responsibility factory files (`get-articles.controller.ts`, `create-article.controller.ts`, `get-article-by-slug.controller.ts`) +- `packages/blog/src/interface-adapters/controllers/articles.controller.test.ts` — deleted with the controller; tests rewritten in the per-controller test files + ### Task 2: Entities split — old errors.ts files removed - `packages/auth/src/entities/errors.ts` — replaced by `errors/auth.ts` + `errors/common.ts` @@ -118,18 +134,20 @@ Entity model moves (git mv — history preserved): ### 4.1 Use cases — factory function pattern -Applied to all 3 auth use cases (`sign-in`, `sign-up`, `sign-out`): +Applied to all 3 auth use cases (`sign-in`, `sign-up`, `sign-out`) in Task 4, and all 3 blog use cases (`get-articles`, `create-article`, `get-article-by-slug` NEW) in Task 5: - Use cases are now factory functions: `(deps) => async (input) => result` - Each file exports `export type I*UseCase = ReturnType` for DI typing -- Use cases NO LONGER call `authContainer.get()` inside their bodies — all dependencies are passed as factory arguments -- Tests construct mocks directly: `const useCase = signInUseCase(mockUsers, mockAuth); await useCase(input);` +- Use cases NO LONGER call `*Container.get()` inside their bodies — all dependencies are passed as factory arguments +- Tests construct mocks directly: `const useCase = getArticlesUseCase(repo); await useCase({ status: "draft" });` +- NEW `getArticleBySlugUseCase`: previously the slug lookup bypassed the use case layer (controller called repo directly); now the use case owns the `ArticleNotFoundError` throw ### 4.2 Controllers — one per use case -Applied to all 3 auth controllers (`sign-in`, `sign-up`, `sign-out`): +Applied to all 3 auth controllers (`sign-in`, `sign-up`, `sign-out`) in Task 4; blog controllers split in Task 5: -- Controllers were already split (one file per use case) — Task 4 refactors them to factory functions +- Controllers were already split for auth (one file per use case) — Task 4 refactors them to factory functions +- Blog: the multi-method `articles.controller.ts` is deleted and replaced by 3 single-responsibility files - Factory pattern: `(useCase: I*UseCase) => async (input) => result` - Each exports `export type I*Controller = ReturnType` - Validation (Zod `safeParse`) stays inside the controller factory; throws `InputParseError` on failure @@ -149,13 +167,20 @@ Pattern now in place across auth, blog, marketing-pages, navigation (media skipp ### 5.1 Inversify `.toDynamicValue` bindings -Applied to `packages/auth/src/di/module.ts`: +Applied to `packages/auth/src/di/module.ts` (Task 4) and `packages/blog/src/di/module.ts` (Task 5): +**auth:** - `AUTH_SYMBOLS` expanded with 6 new keys: `ISignInUseCase`, `ISignUpUseCase`, `ISignOutUseCase`, `ISignInController`, `ISignUpController`, `ISignOutController` - Use cases bound with `.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))` — dependencies resolved from the container at call time - Controllers bound identically, taking the corresponding use case symbol from the container - Repository and service bindings remain `.to(Mock*)` as the default +**blog:** +- `BLOG_SYMBOLS` expanded with 6 new keys: `IGetArticlesUseCase`, `ICreateArticleUseCase`, `IGetArticleBySlugUseCase`, `IGetArticlesController`, `ICreateArticleController`, `IGetArticleBySlugController` +- All use cases and controllers bound with `.toDynamicValue()` — same pattern as auth +- Repository binding remains `.to(MockArticlesRepository)` as the default +- tRPC router (`integrations/api/router.ts`) updated to resolve controllers via `blogContainer.get(BLOG_SYMBOLS.IXController)` instead of importing controllers directly + ### 5.2 Mock siblings registered as default bindings - `MockUsersRepository` and `MockAuthenticationService` remain the default bindings in `AuthModule` diff --git a/packages/blog/src/application/use-cases/create-article.use-case.test.ts b/packages/blog/src/application/use-cases/create-article.use-case.test.ts index 3a1575d..8535c2b 100644 --- a/packages/blog/src/application/use-cases/create-article.use-case.test.ts +++ b/packages/blog/src/application/use-cases/create-article.use-case.test.ts @@ -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(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", diff --git a/packages/blog/src/application/use-cases/create-article.use-case.ts b/packages/blog/src/application/use-cases/create-article.use-case.ts index d2b813c..b430556 100644 --- a/packages/blog/src/application/use-cases/create-article.use-case.ts +++ b/packages/blog/src/application/use-cases/create-article.use-case.ts @@ -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
{ - const repo = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); +export type ICreateArticleUseCase = ReturnType; - 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
=> { + 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); -} diff --git a/packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts new file mode 100644 index 0000000..daf31e9 --- /dev/null +++ b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts @@ -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); + }); +}); diff --git a/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts new file mode 100644 index 0000000..e1b1459 --- /dev/null +++ b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts @@ -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; + +export const getArticleBySlugUseCase = + (articlesRepository: IArticlesRepository) => + async (input: { slug: string }): Promise
=> { + const article = await articlesRepository.getArticleBySlug(input.slug); + if (!article) { + throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`); + } + return article; + }; diff --git a/packages/blog/src/application/use-cases/get-articles.use-case.test.ts b/packages/blog/src/application/use-cases/get-articles.use-case.test.ts index dbf7dbf..96a6f18 100644 --- a/packages/blog/src/application/use-cases/get-articles.use-case.test.ts +++ b/packages/blog/src/application/use-cases/get-articles.use-case.test.ts @@ -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(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"); }); diff --git a/packages/blog/src/application/use-cases/get-articles.use-case.ts b/packages/blog/src/application/use-cases/get-articles.use-case.ts index 2800fb8..14a9680 100644 --- a/packages/blog/src/application/use-cases/get-articles.use-case.ts +++ b/packages/blog/src/application/use-cases/get-articles.use-case.ts @@ -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 { - const repo = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); - return repo.getArticles(options); -} +export type IGetArticlesUseCase = ReturnType; + +export const getArticlesUseCase = + (articlesRepository: IArticlesRepository) => + async (options?: { + status?: string; + authorId?: string; + limit?: number; + offset?: number; + }): Promise => { + return articlesRepository.getArticles(options); + }; diff --git a/packages/blog/src/di/container.test.ts b/packages/blog/src/di/container.test.ts index bf4c924..5cead6f 100644 --- a/packages/blog/src/di/container.test.ts +++ b/packages/blog/src/di/container.test.ts @@ -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(BLOG_SYMBOLS.IArticlesRepository) - .toConstantValue(custom); - const resolved = blogContainer.get( - 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"); }); }); diff --git a/packages/blog/src/di/module.ts b/packages/blog/src/di/module.ts index 1e763ad..fed03f0 100644 --- a/packages/blog/src/di/module.ts +++ b/packages/blog/src/di/module.ts @@ -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(BLOG_SYMBOLS.IArticlesRepository).to( - MockArticlesRepository, + bind(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository); + + bind(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue((ctx) => + getArticlesUseCase( + ctx.container.get(BLOG_SYMBOLS.IArticlesRepository), + ), + ); + + bind(BLOG_SYMBOLS.ICreateArticleUseCase).toDynamicValue((ctx) => + createArticleUseCase( + ctx.container.get(BLOG_SYMBOLS.IArticlesRepository), + ), + ); + + bind(BLOG_SYMBOLS.IGetArticleBySlugUseCase).toDynamicValue((ctx) => + getArticleBySlugUseCase( + ctx.container.get(BLOG_SYMBOLS.IArticlesRepository), + ), + ); + + bind(BLOG_SYMBOLS.IGetArticlesController).toDynamicValue((ctx) => + getArticlesController( + ctx.container.get(BLOG_SYMBOLS.IGetArticlesUseCase), + ), + ); + + bind(BLOG_SYMBOLS.ICreateArticleController).toDynamicValue((ctx) => + createArticleController( + ctx.container.get(BLOG_SYMBOLS.ICreateArticleUseCase), + ), + ); + + bind(BLOG_SYMBOLS.IGetArticleBySlugController).toDynamicValue((ctx) => + getArticleBySlugController( + ctx.container.get(BLOG_SYMBOLS.IGetArticleBySlugUseCase), + ), ); }); diff --git a/packages/blog/src/di/symbols.ts b/packages/blog/src/di/symbols.ts index 2033cfe..34190c8 100644 --- a/packages/blog/src/di/symbols.ts +++ b/packages/blog/src/di/symbols.ts @@ -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; diff --git a/packages/blog/src/integrations/api/router.test.ts b/packages/blog/src/integrations/api/router.test.ts index 4cec440..fd5a761 100644 --- a/packages/blog/src/integrations/api/router.test.ts +++ b/packages/blog/src/integrations/api/router.test.ts @@ -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(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); + }); }); diff --git a/packages/blog/src/integrations/api/router.ts b/packages/blog/src/integrations/api/router.ts index ec0c6f2..664f787 100644 --- a/packages/blog/src/integrations/api/router.ts +++ b/packages/blog/src/integrations/api/router.ts @@ -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( + 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( + 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( + BLOG_SYMBOLS.ICreateArticleController, + ); + return ctrl(input); + }), }); export type BlogRouter = typeof blogRouter; diff --git a/packages/blog/src/interface-adapters/controllers/articles.controller.test.ts b/packages/blog/src/interface-adapters/controllers/articles.controller.test.ts deleted file mode 100644 index 345737a..0000000 --- a/packages/blog/src/interface-adapters/controllers/articles.controller.test.ts +++ /dev/null @@ -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(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); - }); - }); -}); diff --git a/packages/blog/src/interface-adapters/controllers/articles.controller.ts b/packages/blog/src/interface-adapters/controllers/articles.controller.ts deleted file mode 100644 index fc0aa4a..0000000 --- a/packages/blog/src/interface-adapters/controllers/articles.controller.ts +++ /dev/null @@ -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>, -): Promise
{ - 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>, -): Promise { - 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
{ - const parsed = getBySlugInputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid get-article-by-slug input", { - cause: parsed.error, - }); - } - const repo = blogContainer.get( - BLOG_SYMBOLS.IArticlesRepository, - ); - return repo.getArticleBySlug(parsed.data.slug); -} diff --git a/packages/blog/src/interface-adapters/controllers/create-article.controller.test.ts b/packages/blog/src/interface-adapters/controllers/create-article.controller.test.ts new file mode 100644 index 0000000..93d6fd5 --- /dev/null +++ b/packages/blog/src/interface-adapters/controllers/create-article.controller.test.ts @@ -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); + }); +}); diff --git a/packages/blog/src/interface-adapters/controllers/create-article.controller.ts b/packages/blog/src/interface-adapters/controllers/create-article.controller.ts new file mode 100644 index 0000000..b19d4ed --- /dev/null +++ b/packages/blog/src/interface-adapters/controllers/create-article.controller.ts @@ -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; + +export const createArticleController = + (createArticleUseCase: ICreateArticleUseCase) => + async (input: Partial>): Promise
=> { + 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, + }); + }; diff --git a/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts new file mode 100644 index 0000000..6cb9c11 --- /dev/null +++ b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts @@ -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); + }); +}); diff --git a/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts new file mode 100644 index 0000000..94f6b29 --- /dev/null +++ b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts @@ -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; + +export const getArticleBySlugController = + (getArticleBySlugUseCase: IGetArticleBySlugUseCase) => + async (input: Partial>): Promise
=> { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error }); + } + return getArticleBySlugUseCase(parsed.data); + }; diff --git a/packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts b/packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts new file mode 100644 index 0000000..96a9b37 --- /dev/null +++ b/packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts @@ -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), + ).rejects.toBeInstanceOf(InputParseError); + }); +}); diff --git a/packages/blog/src/interface-adapters/controllers/get-articles.controller.ts b/packages/blog/src/interface-adapters/controllers/get-articles.controller.ts new file mode 100644 index 0000000..ae3aa7d --- /dev/null +++ b/packages/blog/src/interface-adapters/controllers/get-articles.controller.ts @@ -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; + +export const getArticlesController = + (getArticlesUseCase: IGetArticlesUseCase) => + async (input: Partial>): Promise => { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-articles input", { cause: parsed.error }); + } + return getArticlesUseCase(parsed.data); + }; diff --git a/packages/blog/tests/articles.feature.test.ts b/packages/blog/tests/articles.feature.test.ts index ef8f989..caf36f4 100644 --- a/packages/blog/tests/articles.feature.test.ts +++ b/packages/blog/tests/articles.feature.test.ts @@ -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(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); + }); });