From 2b67964213dd2b8bafdfa7d400b49e45b1b8d777 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 15:41:08 +0200 Subject: [PATCH] refactor(media): unify use-case I/O schemas + presenter + feature error map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Plan 9 (spec R1-R28): - Use cases: input + output schemas (getMedia, listMedia); deleteMedia has input schema only (void output, R12 — no presenter). - Controllers: unknown input + identity presenter on getMedia/listMedia; Promise on deleteMedia. - New integrations/api/procedures.ts with mediaProcedure ([InputParseError → BAD_REQUEST], [MediaNotFoundError → NOT_FOUND]). - Router uses mediaProcedure + .input(xInputSchema). - src/index.ts exports schemas + types; src/ui/index.ts placeholder (media has no queries today); package.json adds ./ui subpath. - R25 + R26 tests added. 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 Opus 4.7 (1M context) --- .../2026-05-06-input-output-unification.md | 19 +++ packages/media/package.json | 1 + .../use-cases/delete-media.use-case.ts | 11 +- .../use-cases/get-media.use-case.test.ts | 17 ++- .../use-cases/get-media.use-case.ts | 17 ++- .../use-cases/list-media.use-case.test.ts | 21 ++- .../use-cases/list-media.use-case.ts | 23 ++- packages/media/src/index.ts | 26 ++++ .../media/src/integrations/api/procedures.ts | 12 ++ .../media/src/integrations/api/router.test.ts | 138 ++++++++++++++++++ packages/media/src/integrations/api/router.ts | 56 +++---- .../controllers/delete-media.controller.ts | 16 +- .../controllers/get-media.controller.ts | 21 +-- .../controllers/list-media.controller.ts | 22 +-- packages/media/src/ui/index.ts | 4 + 15 files changed, 330 insertions(+), 74 deletions(-) create mode 100644 packages/media/src/integrations/api/procedures.ts create mode 100644 packages/media/src/integrations/api/router.test.ts create mode 100644 packages/media/src/ui/index.ts diff --git a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md index 221d81a..083b301 100644 --- a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md +++ b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md @@ -25,6 +25,9 @@ doc-update items so docs are written once for the post-Plan-9 state. - packages/marketing-pages/src/ui/index.ts — re-exports pageBySlugQuery and siteSettingsQuery from ./query (moved from feature root index) - packages/navigation/src/integrations/api/procedures.ts — navigationProcedure with feature error map (InputParseError → BAD_REQUEST, HeaderNotFoundError → NOT_FOUND) - packages/navigation/src/ui/index.ts — re-exports headerQuery from ./query (moved from feature root index) +- packages/media/src/integrations/api/procedures.ts — mediaProcedure with feature error map (InputParseError → BAD_REQUEST, MediaNotFoundError → NOT_FOUND) +- packages/media/src/ui/index.ts — placeholder UI surface (no queries today; media has no React Query option builders) +- packages/media/src/integrations/api/router.test.ts — R26 router error-mapping tests ## 2. Files modified @@ -66,6 +69,15 @@ doc-update items so docs are written once for the post-Plan-9 state. - packages/navigation/src/index.ts — removed headerQuery re-export (moved to ./ui); exports getHeaderInputSchema, getHeaderOutputSchema, GetHeaderInput, GetHeaderOutput, IGetHeaderUseCase, IGetHeaderController; added HeaderNotFoundError + InputParseError re-exports - packages/navigation/package.json — added ./ui subpath export - packages/navigation/src/integrations/api/router.test.ts — updated to call caller.header({}); added R26 describe block; uses beforeEach/afterEach to rebind container +- packages/media/src/application/use-cases/get-media.use-case.ts — getMediaInputSchema + getMediaOutputSchema (= mediaSchema); output.parse; types exported +- packages/media/src/application/use-cases/list-media.use-case.ts — listMediaInputSchema (limit/offset strict object) + listMediaOutputSchema (z.array(mediaSchema)); output.parse; types exported +- packages/media/src/application/use-cases/delete-media.use-case.ts — deleteMediaInputSchema (void output — no xOutputSchema); types exported; input now typed DeleteMediaInput +- packages/media/src/interface-adapters/controllers/get-media.controller.ts — identity presenter; unknown input; imports getMediaInputSchema from use-case +- packages/media/src/interface-adapters/controllers/list-media.controller.ts — identity presenter; unknown input; imports listMediaInputSchema from use-case +- packages/media/src/interface-adapters/controllers/delete-media.controller.ts — no presenter (void); unknown input; imports deleteMediaInputSchema from use-case +- packages/media/src/integrations/api/router.ts — uses mediaProcedure + .input(xInputSchema) for all 3 procedures; no more publicProcedure or local schema redefinitions +- packages/media/src/index.ts — exports schemas (getMediaInputSchema/Output, listMediaInputSchema/Output, deleteMediaInputSchema) + types + IUseCase/IController aliases +- packages/media/package.json — added ./ui subpath export ## 3. Pattern changes (code-level) @@ -74,18 +86,21 @@ auth migrated: all 3 use cases. signIn and signUp export xInputSchema + xOutputS 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. marketing-pages migrated: both use cases. getPageBySlug exports getPageBySlugInputSchema + getPageBySlugOutputSchema + types; returns undefined for missing page (preserving existing semantics) — parse only called when page found. getSiteSettings exports getSiteSettingsInputSchema (z.object({}).strict() — void input per R5) + getSiteSettingsOutputSchema + types; takes `_input: GetSiteSettingsInput` to satisfy uniform input contract. navigation migrated: single use case (getHeader). Exports getHeaderInputSchema (z.object({}).strict() — void input per R5) + getHeaderOutputSchema (= headerSchema) + types; takes `_input: GetHeaderInput`; throws HeaderNotFoundError when repository returns falsy (existing behavior preserved); ends with `getHeaderOutputSchema.parse(header)`. +media migrated: all 3 use cases. getMedia exports getMediaInputSchema + getMediaOutputSchema (= mediaSchema) + types; ends with getMediaOutputSchema.parse(media). listMedia exports listMediaInputSchema (strict object with optional int limit/offset) + listMediaOutputSchema (z.array(mediaSchema)) + types; ends with listMediaOutputSchema.parse(result). deleteMedia exports deleteMediaInputSchema only (void output per R12); no xOutputSchema. ### 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`. 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`. All accept `unknown` input and safeparse with the imported use-case schema. marketing-pages migrated: both controllers. getPageBySlug has identity presenter; return type is `ReturnType | undefined` (preserves missing-page semantics). getSiteSettings has identity presenter; return type is `ReturnType`. Both accept `unknown` input and safeparse with the imported use-case schema. navigation migrated: single controller (getHeaderController). Identity presenter; return type `ReturnType`. Accepts `unknown` input and safeparses with getHeaderInputSchema imported from use-case file. +media migrated: all 3 controllers. getMediaController and listMediaController have `function presenter(value: XOutput)` that is identity (`return value`); return type is `ReturnType`. deleteMediaController has no presenter (void return); return type is `Promise`. All 3 accept `unknown` input and safeparse with the imported use-case schema. ### 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. 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. marketing-pages migrated: marketingPagesProcedure in procedures.ts wraps defineErrorMiddleware with 2-tuple map (InputParseError → BAD_REQUEST, PageNotFoundError → NOT_FOUND). Router uses `marketingPagesProcedure.input(xInputSchema)` for both procedures. siteSettings now uses `.input(getSiteSettingsInputSchema)` (was a no-input `.query()`). navigation migrated: navigationProcedure in procedures.ts wraps defineErrorMiddleware with 2-tuple map (InputParseError → BAD_REQUEST, HeaderNotFoundError → NOT_FOUND). Router uses `navigationProcedure.input(getHeaderInputSchema)` (was a no-input `.query()` with publicProcedure). +media migrated: mediaProcedure in procedures.ts wraps defineErrorMiddleware with 2-tuple map (InputParseError → BAD_REQUEST, MediaNotFoundError → NOT_FOUND). Router uses `mediaProcedure.input(xInputSchema)` for all 3 procedures — getMedia (query), listMedia (query), deleteMedia (mutation). ## 4. Error-middleware adoption @@ -101,12 +116,14 @@ auth: `./ui` subpath added to package.json exports; `src/ui/index.ts` placeholde blog: `./ui` subpath added to package.json exports; `src/ui/index.ts` created re-exporting articleBySlugQuery and listArticlesQuery from ./query. marketing-pages: `./ui` subpath added to package.json exports; `src/ui/index.ts` created re-exporting pageBySlugQuery and siteSettingsQuery from ./query. navigation: `./ui` subpath added to package.json exports; `src/ui/index.ts` created re-exporting headerQuery from ./query (moved from feature root index). +media: `./ui` subpath added to package.json exports; `src/ui/index.ts` placeholder created (media has no query builders today — no existing UI to migrate). ### 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. 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. marketing-pages: root `src/index.ts` removed pageBySlugQuery/siteSettingsQuery re-exports (moved to ./ui); now exports getPageBySlugInputSchema/Output, getSiteSettingsInputSchema/Output, all XInput/XOutput types, IUseCase aliases, and IController aliases. navigation: root `src/index.ts` removed headerQuery re-export (moved to ./ui); now exports getHeaderInputSchema, getHeaderOutputSchema, GetHeaderInput, GetHeaderOutput, IGetHeaderUseCase, IGetHeaderController; also exports HeaderNotFoundError and InputParseError. +media: root `src/index.ts` kept existing Media type + MediaNotFoundError + InputParseError + MediaRouter re-exports; added getMediaInputSchema/Output, listMediaInputSchema/Output, deleteMediaInputSchema and all XInput/XOutput types; added IUseCase/IController type aliases. No query builders to remove (media had no UI exports). ## 6. Test additions @@ -115,12 +132,14 @@ auth: signIn and signUp each have 2 new R25 tests — one verifying that a malfo 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. marketing-pages: getPageBySlug has 1 R25 test verifying that a repository returning a malformed page object throws ZodError (instanceof). getSiteSettings has 1 R25 test using an inline malformed repository mock that returns `{ siteName: "" }` (fails min(1) constraint) to assert ZodError (instanceof). navigation: getHeader has 1 R25 test using an inline malformed repository mock that returns `{ items: [{ label: "", href: "/", external: false }] }` (label fails min(1) constraint) to assert ZodError (instanceof). +media: getMedia has 2 R25 tests — one verifying getMediaOutputSchema parses a valid media object, one verifying that a repository returning a malformed object (missing filesize/filename/mimeType) throws ZodError (instanceof). listMedia has 2 R25 tests — same pattern using listMediaOutputSchema. deleteMedia is void — no R25 test. ### 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). 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). marketing-pages: 2 new R26 tests in router.test.ts — BAD_REQUEST on pageBySlug with empty input ({} as { slug: string }) (schema validation at tRPC procedure boundary); undefined return for missing slug confirmed (use case returns undefined rather than throwing PageNotFoundError, so NOT_FOUND mapping does not apply for this use case). navigation: 2 new R26 tests in router.test.ts — BAD_REQUEST on header with extra fields (strict() z.object({}) rejects unknown keys via InputParseError); NOT_FOUND on header when a NullHeaderRepository (inline @injectable class) causes HeaderNotFoundError (container rebound inline for the test). +media: 4 new R26 tests in router.test.ts — NOT_FOUND on getMedia with nonexistent id (MediaNotFoundError translation via mediaProcedure); BAD_REQUEST on getMedia with empty input ({} as { id: string }) (schema validation at tRPC procedure boundary); NOT_FOUND on deleteMedia with nonexistent id; NOT_FOUND via NullMediaRepository inline rebind confirming mediaProcedure error map applies. ### 6.3 R27/R28 — presenter shape tests diff --git a/packages/media/package.json b/packages/media/package.json index 6c434f9..41cfc63 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./ui": "./src/ui/index.ts", "./cms": "./src/integrations/cms/index.ts", "./api": "./src/integrations/api/index.ts", "./di/bind-production": "./src/di/bind-production.ts" diff --git a/packages/media/src/application/use-cases/delete-media.use-case.ts b/packages/media/src/application/use-cases/delete-media.use-case.ts index 26b1d77..1381195 100644 --- a/packages/media/src/application/use-cases/delete-media.use-case.ts +++ b/packages/media/src/application/use-cases/delete-media.use-case.ts @@ -1,11 +1,20 @@ +import { z } from "zod"; + import { MediaNotFoundError } from "../../entities/errors/media"; import type { IMediaRepository } from "../repositories/media.repository.interface"; +// ── Input ──────────────────────────────────────────────────────────────── +export const deleteMediaInputSchema = z.object({ id: z.string().min(1) }).strict(); +export type DeleteMediaInput = z.infer; + +// No output schema — use case returns void. + +// ── Use case ───────────────────────────────────────────────────────────── export type IDeleteMediaUseCase = ReturnType; export const deleteMediaUseCase = (mediaRepository: IMediaRepository) => - async (input: { id: string }): Promise => { + async (input: DeleteMediaInput): Promise => { const existing = await mediaRepository.getMedia(input.id); if (!existing) { throw new MediaNotFoundError(`Media with id "${input.id}" not found`); diff --git a/packages/media/src/application/use-cases/get-media.use-case.test.ts b/packages/media/src/application/use-cases/get-media.use-case.test.ts index 1c288f4..8ff7f26 100644 --- a/packages/media/src/application/use-cases/get-media.use-case.test.ts +++ b/packages/media/src/application/use-cases/get-media.use-case.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { getMediaUseCase } from "@/application/use-cases/get-media.use-case"; +import { ZodError } from "zod"; +import { getMediaUseCase, getMediaOutputSchema } from "@/application/use-cases/get-media.use-case"; import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; import { MediaNotFoundError } from "@/entities/errors/media"; import { mediaFactory } from "@/__factories__/media.factory"; @@ -23,4 +24,18 @@ describe("getMediaUseCase", () => { await expect(useCase({ id: "missing" })).rejects.toThrow(MediaNotFoundError); }); + + it("R25 — parses valid output without error", async () => { + const validMedia = mediaFactory.build({ id: "m-r25" }); + expect(() => getMediaOutputSchema.parse(validMedia)).not.toThrow(); + }); + + it("R25 — throws ZodError when repository returns malformed media", async () => { + const repo = new MockMediaRepository(); + // Store a malformed object (missing required fields) via type cast + await repo._store({ id: "bad", alt: "alt", url: "u" } as never); + + const useCase = getMediaUseCase(repo); + await expect(useCase({ id: "bad" })).rejects.toBeInstanceOf(ZodError); + }); }); diff --git a/packages/media/src/application/use-cases/get-media.use-case.ts b/packages/media/src/application/use-cases/get-media.use-case.ts index 5199a68..069349f 100644 --- a/packages/media/src/application/use-cases/get-media.use-case.ts +++ b/packages/media/src/application/use-cases/get-media.use-case.ts @@ -1,15 +1,26 @@ +import { z } from "zod"; + import { MediaNotFoundError } from "../../entities/errors/media"; -import type { Media } from "../../entities/models/media"; +import { mediaSchema } from "../../entities/models/media"; import type { IMediaRepository } from "../repositories/media.repository.interface"; +// ── Input ──────────────────────────────────────────────────────────────── +export const getMediaInputSchema = z.object({ id: z.string().min(1) }).strict(); +export type GetMediaInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const getMediaOutputSchema = mediaSchema; +export type GetMediaOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── export type IGetMediaUseCase = ReturnType; export const getMediaUseCase = (mediaRepository: IMediaRepository) => - async (input: { id: string }): Promise => { + async (input: GetMediaInput): Promise => { const media = await mediaRepository.getMedia(input.id); if (!media) { throw new MediaNotFoundError(`Media with id "${input.id}" not found`); } - return media; + return getMediaOutputSchema.parse(media); }; diff --git a/packages/media/src/application/use-cases/list-media.use-case.test.ts b/packages/media/src/application/use-cases/list-media.use-case.test.ts index 8bf3be6..eedd977 100644 --- a/packages/media/src/application/use-cases/list-media.use-case.test.ts +++ b/packages/media/src/application/use-cases/list-media.use-case.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { listMediaUseCase } from "@/application/use-cases/list-media.use-case"; +import { ZodError } from "zod"; +import { listMediaUseCase, listMediaOutputSchema } from "@/application/use-cases/list-media.use-case"; import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; import { mediaFactory } from "@/__factories__/media.factory"; @@ -7,7 +8,7 @@ describe("listMediaUseCase", () => { it("returns empty array when no media exists", async () => { const repo = new MockMediaRepository(); const useCase = listMediaUseCase(repo); - const result = await useCase(); + const result = await useCase({}); expect(result).toHaveLength(0); }); @@ -17,7 +18,7 @@ describe("listMediaUseCase", () => { await repo._store(mediaFactory.build()); const useCase = listMediaUseCase(repo); - const result = await useCase(); + const result = await useCase({}); expect(result).toHaveLength(2); }); @@ -31,4 +32,18 @@ describe("listMediaUseCase", () => { const result = await useCase({ limit: 2, offset: 1 }); expect(result).toHaveLength(2); }); + + it("R25 — parses valid output without error", async () => { + const items = [mediaFactory.build(), mediaFactory.build()]; + expect(() => listMediaOutputSchema.parse(items)).not.toThrow(); + }); + + it("R25 — throws ZodError when repository returns malformed items", async () => { + const repo = new MockMediaRepository(); + // Store a malformed object (missing required fields) + await repo._store({ id: "bad", alt: "alt", url: "u" } as never); + + const useCase = listMediaUseCase(repo); + await expect(useCase({})).rejects.toBeInstanceOf(ZodError); + }); }); diff --git a/packages/media/src/application/use-cases/list-media.use-case.ts b/packages/media/src/application/use-cases/list-media.use-case.ts index fc83edb..c6e2fa8 100644 --- a/packages/media/src/application/use-cases/list-media.use-case.ts +++ b/packages/media/src/application/use-cases/list-media.use-case.ts @@ -1,10 +1,27 @@ -import type { Media } from "../../entities/models/media"; +import { z } from "zod"; + +import { mediaSchema } from "../../entities/models/media"; import type { IMediaRepository } from "../repositories/media.repository.interface"; +// ── Input ──────────────────────────────────────────────────────────────── +export const listMediaInputSchema = z + .object({ + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), + }) + .strict(); +export type ListMediaInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const listMediaOutputSchema = z.array(mediaSchema); +export type ListMediaOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── export type IListMediaUseCase = ReturnType; export const listMediaUseCase = (mediaRepository: IMediaRepository) => - async (opts?: { limit?: number; offset?: number }): Promise => { - return mediaRepository.listMedia(opts); + async (input: ListMediaInput): Promise => { + const result = await mediaRepository.listMedia(input); + return listMediaOutputSchema.parse(result); }; diff --git a/packages/media/src/index.ts b/packages/media/src/index.ts index 4f20c6f..fd57650 100644 --- a/packages/media/src/index.ts +++ b/packages/media/src/index.ts @@ -2,3 +2,29 @@ export type { Media } from "./entities/models/media"; export { MediaNotFoundError } from "./entities/errors/media"; export { InputParseError } from "./entities/errors/common"; export type { MediaRouter } from "./integrations/api/router"; + +// Use case schemas + types (Plan 9 R18) +export { + getMediaInputSchema, + getMediaOutputSchema, + type GetMediaInput, + type GetMediaOutput, + type IGetMediaUseCase, +} from "./application/use-cases/get-media.use-case"; +export { + listMediaInputSchema, + listMediaOutputSchema, + type ListMediaInput, + type ListMediaOutput, + type IListMediaUseCase, +} from "./application/use-cases/list-media.use-case"; +export { + deleteMediaInputSchema, + type DeleteMediaInput, + type IDeleteMediaUseCase, +} from "./application/use-cases/delete-media.use-case"; + +// Controller type aliases +export type { IGetMediaController } from "./interface-adapters/controllers/get-media.controller"; +export type { IListMediaController } from "./interface-adapters/controllers/list-media.controller"; +export type { IDeleteMediaController } from "./interface-adapters/controllers/delete-media.controller"; diff --git a/packages/media/src/integrations/api/procedures.ts b/packages/media/src/integrations/api/procedures.ts new file mode 100644 index 0000000..98b3490 --- /dev/null +++ b/packages/media/src/integrations/api/procedures.ts @@ -0,0 +1,12 @@ +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +import { MediaNotFoundError } from "../../entities/errors/media"; +import { InputParseError } from "../../entities/errors/common"; + +export const mediaProcedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [MediaNotFoundError, "NOT_FOUND"], + ]), +); diff --git a/packages/media/src/integrations/api/router.test.ts b/packages/media/src/integrations/api/router.test.ts new file mode 100644 index 0000000..12dc809 --- /dev/null +++ b/packages/media/src/integrations/api/router.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { injectable } from "inversify"; +import { mediaContainer } from "@/di/container"; +import { MediaModule } from "@/di/module"; +import { MEDIA_SYMBOLS } from "@/di/symbols"; +import { getMediaUseCase } from "@/application/use-cases/get-media.use-case"; +import { listMediaUseCase } from "@/application/use-cases/list-media.use-case"; +import { deleteMediaUseCase } from "@/application/use-cases/delete-media.use-case"; +import { getMediaController } from "@/interface-adapters/controllers/get-media.controller"; +import { listMediaController } from "@/interface-adapters/controllers/list-media.controller"; +import { deleteMediaController } from "@/interface-adapters/controllers/delete-media.controller"; +import { mediaRouter } from "@/integrations/api/router"; + +describe("mediaRouter", () => { + beforeEach(() => { + mediaContainer.unbindAll(); + mediaContainer.load(MediaModule); + }); + + afterEach(() => { + mediaContainer.unbindAll(); + }); + + it("exposes getMedia, listMedia, deleteMedia procedures", () => { + const names = Object.keys(mediaRouter._def.procedures); + expect(names).toContain("getMedia"); + expect(names).toContain("listMedia"); + expect(names).toContain("deleteMedia"); + }); + + it("listMedia returns empty array by default", async () => { + const caller = mediaRouter.createCaller({}); + const result = await caller.listMedia({}); + expect(result).toEqual([]); + }); +}); + +describe("mediaRouter (R26 error mapping)", () => { + beforeEach(() => { + mediaContainer.unbindAll(); + mediaContainer.load(MediaModule); + }); + + afterEach(() => { + mediaContainer.unbindAll(); + }); + + it("translates MediaNotFoundError → NOT_FOUND when id is missing", async () => { + const caller = mediaRouter.createCaller({}); + try { + await caller.getMedia({ id: "nonexistent-id" }); + 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 on getMedia with empty id", async () => { + const caller = mediaRouter.createCaller({}); + try { + await caller.getMedia({} as unknown as { id: string }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("BAD_REQUEST"); + } + }); + + it("translates MediaNotFoundError → NOT_FOUND on deleteMedia with missing id", async () => { + const caller = mediaRouter.createCaller({}); + try { + await caller.deleteMedia({ id: "nonexistent-id" }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } + }); + + it("translates MediaNotFoundError → NOT_FOUND via NullMediaRepository", async () => { + @injectable() + class NullMediaRepository { + async getMedia() { + return undefined as never; + } + async listMedia() { + return [] as never; + } + async deleteMedia() { + return; + } + } + + mediaContainer.unbindAll(); + mediaContainer.bind(MEDIA_SYMBOLS.IMediaRepository).to(NullMediaRepository); + mediaContainer + .bind(MEDIA_SYMBOLS.IGetMediaUseCase) + .toDynamicValue((ctx) => + getMediaUseCase(ctx.container.get(MEDIA_SYMBOLS.IMediaRepository)), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IListMediaUseCase) + .toDynamicValue((ctx) => + listMediaUseCase(ctx.container.get(MEDIA_SYMBOLS.IMediaRepository)), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IDeleteMediaUseCase) + .toDynamicValue((ctx) => + deleteMediaUseCase(ctx.container.get(MEDIA_SYMBOLS.IMediaRepository)), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IGetMediaController) + .toDynamicValue((ctx) => + getMediaController(ctx.container.get(MEDIA_SYMBOLS.IGetMediaUseCase)), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IListMediaController) + .toDynamicValue((ctx) => + listMediaController(ctx.container.get(MEDIA_SYMBOLS.IListMediaUseCase)), + ); + mediaContainer + .bind(MEDIA_SYMBOLS.IDeleteMediaController) + .toDynamicValue((ctx) => + deleteMediaController(ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase)), + ); + + const caller = mediaRouter.createCaller({}); + try { + await caller.getMedia({ id: "any-id" }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } + }); +}); diff --git a/packages/media/src/integrations/api/router.ts b/packages/media/src/integrations/api/router.ts index 3f53114..c7a3608 100644 --- a/packages/media/src/integrations/api/router.ts +++ b/packages/media/src/integrations/api/router.ts @@ -1,45 +1,31 @@ -import { z } from "zod"; -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; +import { router } from "@repo/core-shared/trpc/init"; + import { mediaContainer } from "../../di/container"; import { MEDIA_SYMBOLS } from "../../di/symbols"; + +import { getMediaInputSchema } from "../../application/use-cases/get-media.use-case"; +import { listMediaInputSchema } from "../../application/use-cases/list-media.use-case"; +import { deleteMediaInputSchema } from "../../application/use-cases/delete-media.use-case"; + import type { IGetMediaController } from "../../interface-adapters/controllers/get-media.controller"; import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller"; import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller"; +import { mediaProcedure } from "./procedures"; + export const mediaRouter = router({ - getMedia: publicProcedure - .input(z.object({ id: z.string().min(1) })) - .query(({ input }) => { - const ctrl = mediaContainer.get( - MEDIA_SYMBOLS.IGetMediaController, - ); - return ctrl(input); - }), - - listMedia: publicProcedure - .input( - z - .object({ - limit: z.number().optional(), - offset: z.number().optional(), - }) - .optional(), - ) - .query(({ input }) => { - const ctrl = mediaContainer.get( - MEDIA_SYMBOLS.IListMediaController, - ); - return ctrl(input ?? {}); - }), - - deleteMedia: publicProcedure - .input(z.object({ id: z.string().min(1) })) - .mutation(({ input }) => { - const ctrl = mediaContainer.get( - MEDIA_SYMBOLS.IDeleteMediaController, - ); - return ctrl(input); - }), + getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => { + const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IGetMediaController); + return ctrl(input); + }), + listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => { + const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IListMediaController); + return ctrl(input); + }), + deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => { + const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IDeleteMediaController); + return ctrl(input); + }), }); export type MediaRouter = typeof mediaRouter; diff --git a/packages/media/src/interface-adapters/controllers/delete-media.controller.ts b/packages/media/src/interface-adapters/controllers/delete-media.controller.ts index 4c98e04..9271bdf 100644 --- a/packages/media/src/interface-adapters/controllers/delete-media.controller.ts +++ b/packages/media/src/interface-adapters/controllers/delete-media.controller.ts @@ -1,19 +1,17 @@ -import { z } from "zod"; import { InputParseError } from "../../entities/errors/common"; -import type { IDeleteMediaUseCase } from "../../application/use-cases/delete-media.use-case"; - -const inputSchema = z.object({ - id: z.string().min(1), -}); +import { + deleteMediaInputSchema, + type IDeleteMediaUseCase, +} from "../../application/use-cases/delete-media.use-case"; export type IDeleteMediaController = ReturnType; export const deleteMediaController = (deleteMediaUseCase: IDeleteMediaUseCase) => - async (input: Partial>): Promise => { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise => { + const parsed = deleteMediaInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid delete-media input", { cause: parsed.error }); } - return deleteMediaUseCase(parsed.data); + await deleteMediaUseCase(parsed.data); }; diff --git a/packages/media/src/interface-adapters/controllers/get-media.controller.ts b/packages/media/src/interface-adapters/controllers/get-media.controller.ts index 1a5a1da..f38d1a7 100644 --- a/packages/media/src/interface-adapters/controllers/get-media.controller.ts +++ b/packages/media/src/interface-adapters/controllers/get-media.controller.ts @@ -1,20 +1,23 @@ -import { z } from "zod"; import { InputParseError } from "../../entities/errors/common"; -import type { Media } from "../../entities/models/media"; -import type { IGetMediaUseCase } from "../../application/use-cases/get-media.use-case"; +import { + getMediaInputSchema, + type GetMediaOutput, + type IGetMediaUseCase, +} from "../../application/use-cases/get-media.use-case"; -const inputSchema = z.object({ - id: z.string().min(1), -}); +function presenter(value: GetMediaOutput) { + return value; +} export type IGetMediaController = ReturnType; export const getMediaController = (getMediaUseCase: IGetMediaUseCase) => - async (input: Partial>): Promise => { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise> => { + const parsed = getMediaInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-media input", { cause: parsed.error }); } - return getMediaUseCase(parsed.data); + const result = await getMediaUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/media/src/interface-adapters/controllers/list-media.controller.ts b/packages/media/src/interface-adapters/controllers/list-media.controller.ts index edd8d55..c94b5e0 100644 --- a/packages/media/src/interface-adapters/controllers/list-media.controller.ts +++ b/packages/media/src/interface-adapters/controllers/list-media.controller.ts @@ -1,21 +1,23 @@ -import { z } from "zod"; import { InputParseError } from "../../entities/errors/common"; -import type { Media } from "../../entities/models/media"; -import type { IListMediaUseCase } from "../../application/use-cases/list-media.use-case"; +import { + listMediaInputSchema, + type ListMediaOutput, + type IListMediaUseCase, +} from "../../application/use-cases/list-media.use-case"; -const inputSchema = z.object({ - limit: z.number().optional(), - offset: z.number().optional(), -}); +function presenter(value: ListMediaOutput) { + return value; +} export type IListMediaController = ReturnType; export const listMediaController = (listMediaUseCase: IListMediaUseCase) => - async (input: Partial>): Promise => { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise> => { + const parsed = listMediaInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid list-media input", { cause: parsed.error }); } - return listMediaUseCase(parsed.data); + const result = await listMediaUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/media/src/ui/index.ts b/packages/media/src/ui/index.ts new file mode 100644 index 0000000..d479ff2 --- /dev/null +++ b/packages/media/src/ui/index.ts @@ -0,0 +1,4 @@ +// Media has no React Query option builders today. This file is the +// public UI surface for future components and queries — extend rather +// than re-add to root index.ts. +export {};