From 8a36d803b3f30008165c14d7a3d53962d3cda4b6 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 00:29:05 +0200 Subject: [PATCH] feat(media): full Clean Architecture scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media is now a complete vertical-feature package mirroring auth/blog structure: entities (models + errors), application (repositories + use-cases), infrastructure (real Payload-backed + mock siblings), interface-adapters (per-use-case controllers), DI (symbols + module + container + bind-production), integrations/api (mediaRouter), factory, contract suite, and feature integration tests. Wired into: - packages/core-api/src/root.ts (added `media: mediaRouter`) - apps/web-next/src/server/bind-production.ts (calls bindProductionMedia) - tsconfig.base.json (added @repo/media/api and ./di/bind-production aliases) 56 new tests in @repo/media (13 test files); core-api router test updated to assert media. procedures. All 26 turbo tasks green. Refactor log: §2, §4.1, §4.2, §5.1, §6.1 Spec: §6.5 Co-Authored-By: Claude Sonnet 4.6 --- apps/web-next/src/server/bind-production.ts | 2 + .../2026-05-05-lazar-pattern-conformance.md | 74 +++++++++- packages/core-api/package.json | 1 + packages/core-api/src/root.ts | 2 + packages/core-api/src/router.test.ts | 3 +- packages/media/package.json | 12 +- .../media-repository.contract.ts | 61 ++++++++ packages/media/src/__factories__/index.ts | 3 +- .../media/src/__factories__/media.factory.ts | 10 +- .../media.repository.interface.ts | 7 + .../use-cases/delete-media.use-case.test.ts | 26 ++++ .../use-cases/delete-media.use-case.ts | 14 ++ .../use-cases/get-media.use-case.test.ts | 26 ++++ .../use-cases/get-media.use-case.ts | 15 ++ .../use-cases/list-media.use-case.test.ts | 34 +++++ .../use-cases/list-media.use-case.ts | 10 ++ packages/media/src/di/bind-production.ts | 13 ++ packages/media/src/di/container.test.ts | 52 +++++++ packages/media/src/di/container.ts | 6 + packages/media/src/di/module.ts | 69 ++++++++++ packages/media/src/di/symbols.ts | 11 ++ packages/media/src/entities/errors/common.ts | 5 + .../media/src/entities/errors/media.test.ts | 20 +++ packages/media/src/entities/errors/media.ts | 5 + .../media/src/entities/models/media.test.ts | 51 +++++++ packages/media/src/entities/models/media.ts | 14 ++ packages/media/src/index.ts | 5 +- .../media.repository.mock.test.ts | 7 + .../repositories/media.repository.mock.ts | 29 ++++ .../repositories/media.repository.test.ts | 130 ++++++++++++++++++ .../repositories/media.repository.ts | 76 ++++++++++ packages/media/src/integrations/api/index.ts | 2 + packages/media/src/integrations/api/router.ts | 45 ++++++ .../delete-media.controller.test.ts | 38 +++++ .../controllers/delete-media.controller.ts | 19 +++ .../controllers/get-media.controller.test.ts | 45 ++++++ .../controllers/get-media.controller.ts | 20 +++ .../controllers/list-media.controller.test.ts | 41 ++++++ .../controllers/list-media.controller.ts | 21 +++ packages/media/tests/media.feature.test.ts | 83 +++++++++++ pnpm-lock.yaml | 35 ++++- tsconfig.base.json | 2 + 42 files changed, 1121 insertions(+), 23 deletions(-) create mode 100644 packages/media/src/__contracts__/media-repository.contract.ts create mode 100644 packages/media/src/application/repositories/media.repository.interface.ts create mode 100644 packages/media/src/application/use-cases/delete-media.use-case.test.ts create mode 100644 packages/media/src/application/use-cases/delete-media.use-case.ts create mode 100644 packages/media/src/application/use-cases/get-media.use-case.test.ts create mode 100644 packages/media/src/application/use-cases/get-media.use-case.ts create mode 100644 packages/media/src/application/use-cases/list-media.use-case.test.ts create mode 100644 packages/media/src/application/use-cases/list-media.use-case.ts create mode 100644 packages/media/src/di/bind-production.ts create mode 100644 packages/media/src/di/container.test.ts create mode 100644 packages/media/src/di/container.ts create mode 100644 packages/media/src/di/module.ts create mode 100644 packages/media/src/di/symbols.ts create mode 100644 packages/media/src/entities/errors/common.ts create mode 100644 packages/media/src/entities/errors/media.test.ts create mode 100644 packages/media/src/entities/errors/media.ts create mode 100644 packages/media/src/entities/models/media.test.ts create mode 100644 packages/media/src/entities/models/media.ts create mode 100644 packages/media/src/infrastructure/repositories/media.repository.mock.test.ts create mode 100644 packages/media/src/infrastructure/repositories/media.repository.mock.ts create mode 100644 packages/media/src/infrastructure/repositories/media.repository.test.ts create mode 100644 packages/media/src/infrastructure/repositories/media.repository.ts create mode 100644 packages/media/src/integrations/api/index.ts create mode 100644 packages/media/src/integrations/api/router.ts create mode 100644 packages/media/src/interface-adapters/controllers/delete-media.controller.test.ts create mode 100644 packages/media/src/interface-adapters/controllers/delete-media.controller.ts create mode 100644 packages/media/src/interface-adapters/controllers/get-media.controller.test.ts create mode 100644 packages/media/src/interface-adapters/controllers/get-media.controller.ts create mode 100644 packages/media/src/interface-adapters/controllers/list-media.controller.test.ts create mode 100644 packages/media/src/interface-adapters/controllers/list-media.controller.ts create mode 100644 packages/media/tests/media.feature.test.ts diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index de306ce..aeff114 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -4,6 +4,7 @@ import { bindProductionBlog } from "@repo/blog/di/bind-production"; import { bindProductionAuth } from "@repo/auth/di/bind-production"; import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; import { bindProductionNavigation } from "@repo/navigation/di/bind-production"; +import { bindProductionMedia } from "@repo/media/di/bind-production"; let bound = false; @@ -15,4 +16,5 @@ export async function bindAllProduction(): Promise { bindProductionBlog(resolvedConfig); bindProductionMarketingPages(resolvedConfig); bindProductionNavigation(resolvedConfig); + bindProductionMedia(resolvedConfig); } 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 91bcad7..dea0971 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 @@ -91,6 +91,65 @@ Entity model moves (git mv — history preserved): ## 2. Files added (with purpose) +### Task 8: Media — full Clean Architecture scaffold (~30 new files) + +**Entities:** +- `packages/media/src/entities/models/media.ts` — Zod schema + `Media` type (id, alt, url, filename, mimeType, filesize, width?, height?) +- `packages/media/src/entities/models/media.test.ts` — 4 schema validation tests +- `packages/media/src/entities/errors/media.ts` — `MediaNotFoundError` +- `packages/media/src/entities/errors/media.test.ts` — 3 error class tests +- `packages/media/src/entities/errors/common.ts` — `InputParseError` (media copy) + +**Application:** +- `packages/media/src/application/repositories/media.repository.interface.ts` — `IMediaRepository` with `getMedia`, `listMedia`, `deleteMedia` +- `packages/media/src/application/use-cases/get-media.use-case.ts` — factory function; throws `MediaNotFoundError` when missing +- `packages/media/src/application/use-cases/get-media.use-case.test.ts` — 2 tests +- `packages/media/src/application/use-cases/list-media.use-case.ts` — factory function; delegates to repo +- `packages/media/src/application/use-cases/list-media.use-case.test.ts` — 3 tests +- `packages/media/src/application/use-cases/delete-media.use-case.ts` — factory function; throws `MediaNotFoundError` if not found, then deletes +- `packages/media/src/application/use-cases/delete-media.use-case.test.ts` — 2 tests + +**Infrastructure:** +- `packages/media/src/infrastructure/repositories/media.repository.mock.ts` — `MockMediaRepository` (in-memory; has `_store` test-helper method) +- `packages/media/src/infrastructure/repositories/media.repository.mock.test.ts` — runs contract suite against mock +- `packages/media/src/infrastructure/repositories/media.repository.ts` — `MediaRepository` (Payload-backed; uses `getPayload().find/findByID/delete`) +- `packages/media/src/infrastructure/repositories/media.repository.test.ts` — contract suite + impl-specific mapping tests (9 tests total) + +**Interface-adapters:** +- `packages/media/src/interface-adapters/controllers/get-media.controller.ts` — factory + `IGetMediaController` alias; Zod input validation +- `packages/media/src/interface-adapters/controllers/get-media.controller.test.ts` — 4 tests +- `packages/media/src/interface-adapters/controllers/list-media.controller.ts` — factory + `IListMediaController` alias +- `packages/media/src/interface-adapters/controllers/list-media.controller.test.ts` — 3 tests +- `packages/media/src/interface-adapters/controllers/delete-media.controller.ts` — factory + `IDeleteMediaController` alias +- `packages/media/src/interface-adapters/controllers/delete-media.controller.test.ts` — 3 tests + +**DI:** +- `packages/media/src/di/symbols.ts` — `MEDIA_SYMBOLS` (7 keys: repository + 3 use cases + 3 controllers) +- `packages/media/src/di/module.ts` — `MediaModule`: `.to(MockMediaRepository)` default + `.toDynamicValue()` bindings for all use cases and controllers +- `packages/media/src/di/container.ts` — `mediaContainer` singleton +- `packages/media/src/di/container.test.ts` — 7 tests (all symbols resolve; default binding is mock) +- `packages/media/src/di/bind-production.ts` — `bindProductionMedia(config)` swaps mock for real `MediaRepository` + +**Integrations:** +- `packages/media/src/integrations/api/router.ts` — `mediaRouter` (getMedia, listMedia, deleteMedia); controllers resolved via `mediaContainer.get` +- `packages/media/src/integrations/api/index.ts` — re-exports `mediaRouter` + `MediaRouter` type + +**Contract + factory:** +- `packages/media/src/__contracts__/media-repository.contract.ts` — 6 contract assertions (undefined for missing id, empty list, delete-and-check, limit=0, offset, empty string id) +- `packages/media/src/__factories__/media.factory.ts` — ADAPTED: now imports `Media` from `../entities/models/media` instead of defining an inline interface +- `packages/media/src/__factories__/index.ts` — ADAPTED: re-exports `mediaFactory` + `Media` type from correct paths + +**Feature test:** +- `packages/media/tests/media.feature.test.ts` — 7 tests; full slice via direct injection (no container) + +**Files modified in other packages:** +- `packages/media/package.json` — added `inversify`, `reflect-metadata`, `zod`, `@repo/core-shared`, `@trpc/server` to deps; added `./api` and `./di/bind-production` to exports +- `packages/core-api/package.json` — added `@repo/media: workspace:*` to dependencies +- `packages/core-api/src/root.ts` — added `media: mediaRouter` to `appRouter`; imported from `@repo/media/api` +- `packages/core-api/src/router.test.ts` — updated assertion to include `media.` prefix check +- `apps/web-next/src/server/bind-production.ts` — added `bindProductionMedia(resolvedConfig)` call +- `tsconfig.base.json` — added `@repo/media/api` and `@repo/media/di/bind-production` path aliases + ### Task 4: Real implementations + tests - `packages/auth/src/infrastructure/repositories/users.repository.ts` — real Payload-backed `UsersRepository` (implements `IUsersRepository` via `getPayload`) @@ -150,7 +209,7 @@ 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`) in Task 4, all 3 blog use cases (`get-articles`, `create-article`, `get-article-by-slug` NEW) in Task 5, both marketing-pages use cases (`get-page-by-slug`, `get-site-settings`) in Task 6, and the single navigation use case (`get-header`) in Task 7: +Applied to all 3 auth use cases (`sign-in`, `sign-up`, `sign-out`) in Task 4, all 3 blog use cases (`get-articles`, `create-article`, `get-article-by-slug` NEW) in Task 5, both marketing-pages use cases (`get-page-by-slug`, `get-site-settings`) in Task 6, the single navigation use case (`get-header`) in Task 7, and all 3 media use cases (`get-media`, `list-media`, `delete-media` NEW) in Task 8: - Use cases are now factory functions: `(deps) => async (input) => result` - Each file exports `export type I*UseCase = ReturnType` for DI typing @@ -162,7 +221,7 @@ Applied to all 3 auth use cases (`sign-in`, `sign-up`, `sign-out`) in Task 4, al ### 4.2 Controllers — one per use case -Applied to all 3 auth controllers (`sign-in`, `sign-up`, `sign-out`) in Task 4; blog controllers split in Task 5; marketing-pages controllers split in Task 6: +Applied to all 3 auth controllers (`sign-in`, `sign-up`, `sign-out`) in Task 4; blog controllers split in Task 5; marketing-pages controllers split in Task 6; 3 new media controllers (`get-media`, `list-media`, `delete-media`) created from scratch in Task 8: - 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 @@ -187,7 +246,7 @@ 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` (Task 4), `packages/blog/src/di/module.ts` (Task 5), and `packages/marketing-pages/src/di/module.ts` (Task 6): +Applied to `packages/auth/src/di/module.ts` (Task 4), `packages/blog/src/di/module.ts` (Task 5), `packages/marketing-pages/src/di/module.ts` (Task 6), and `packages/media/src/di/module.ts` (Task 8): **auth:** - `AUTH_SYMBOLS` expanded with 6 new keys: `ISignInUseCase`, `ISignUpUseCase`, `ISignOutUseCase`, `ISignInController`, `ISignUpController`, `ISignOutController` @@ -213,6 +272,13 @@ Applied to `packages/auth/src/di/module.ts` (Task 4), `packages/blog/src/di/modu - Repository binding remains `.to(MockHeaderRepository)` as default - tRPC router updated to resolve controller via `navigationContainer.get(NAVIGATION_SYMBOLS.IGetHeaderController)` +**media (Task 8 — new from scratch):** +- `MEDIA_SYMBOLS` defined with 7 keys: `IMediaRepository`, `IGetMediaUseCase`, `IListMediaUseCase`, `IDeleteMediaUseCase`, `IGetMediaController`, `IListMediaController`, `IDeleteMediaController` +- Repository bound with `.to(MockMediaRepository)` as default +- All 3 use cases + all 3 controllers bound with `.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))` +- `mediaRouter` added to `appRouter` in `packages/core-api/src/root.ts` +- `bindProductionMedia(config)` added to `apps/web-next/src/server/bind-production.ts` + ### 5.2 Mock siblings registered as default bindings - `MockUsersRepository` and `MockAuthenticationService` remain the default bindings in `AuthModule` @@ -223,6 +289,8 @@ Applied to `packages/auth/src/di/module.ts` (Task 4), `packages/blog/src/di/modu ### 6.1 Direct injection (no container rebinding) +Pattern also applied to all new media tests (Task 8) — use case and controller tests construct `MockMediaRepository` directly and inject into factory functions; the feature test (`tests/media.feature.test.ts`) builds the full chain via `buildChain()` helper without touching the DI container. + Applied to all auth use-case tests, controller tests, the router test, and the feature integration test: - **Before:** `beforeEach` unbinds and rebinds symbols on `authContainer` diff --git a/packages/core-api/package.json b/packages/core-api/package.json index cae8df3..6b91d3a 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -17,6 +17,7 @@ "@repo/blog": "workspace:*", "@repo/core-shared": "workspace:*", "@repo/marketing-pages": "workspace:*", + "@repo/media": "workspace:*", "@repo/navigation": "workspace:*", "@trpc/server": "^11.0.0" }, diff --git a/packages/core-api/src/root.ts b/packages/core-api/src/root.ts index 1228d77..6717caa 100644 --- a/packages/core-api/src/root.ts +++ b/packages/core-api/src/root.ts @@ -3,12 +3,14 @@ import { authRouter } from "@repo/auth/api"; import { blogRouter } from "@repo/blog/api"; import { marketingPagesRouter } from "@repo/marketing-pages/api"; import { navigationRouter } from "@repo/navigation/api"; +import { mediaRouter } from "@repo/media/api"; export const appRouter = router({ auth: authRouter, blog: blogRouter, marketingPages: marketingPagesRouter, navigation: navigationRouter, + media: mediaRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/core-api/src/router.test.ts b/packages/core-api/src/router.test.ts index 3dab3a1..c8153da 100644 --- a/packages/core-api/src/router.test.ts +++ b/packages/core-api/src/router.test.ts @@ -2,13 +2,14 @@ import { describe, it, expect } from "vitest"; import { appRouter } from "./root"; describe("appRouter composition", () => { - it("exposes auth, blog, marketingPages, navigation routers", () => { + it("exposes auth, blog, marketingPages, navigation, media routers", () => { const procedures = appRouter._def.procedures; const keys = Object.keys(procedures); expect(keys.some((k) => k.startsWith("auth."))).toBe(true); expect(keys.some((k) => k.startsWith("blog."))).toBe(true); expect(keys.some((k) => k.startsWith("marketingPages."))).toBe(true); expect(keys.some((k) => k.startsWith("navigation."))).toBe(true); + expect(keys.some((k) => k.startsWith("media."))).toBe(true); }); it("blog router has expected procedures", () => { diff --git a/packages/media/package.json b/packages/media/package.json index e7534af..6c434f9 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -5,7 +5,9 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./cms": "./src/integrations/cms/index.ts" + "./cms": "./src/integrations/cms/index.ts", + "./api": "./src/integrations/api/index.ts", + "./di/bind-production": "./src/di/bind-production.ts" }, "scripts": { "build": "tsc --noEmit", @@ -14,12 +16,18 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "payload": "^3.14.0" + "@repo/core-shared": "workspace:*", + "@trpc/server": "^11.0.0", + "inversify": "^6.2.0", + "payload": "^3.14.0", + "reflect-metadata": "^0.2.2", + "zod": "^3.24.0" }, "devDependencies": { "@repo/core-eslint": "workspace:*", "@repo/core-testing": "workspace:*", "@repo/core-typescript": "workspace:*", + "@types/node": "^22.0.0", "vitest": "^3.1.0" } } diff --git a/packages/media/src/__contracts__/media-repository.contract.ts b/packages/media/src/__contracts__/media-repository.contract.ts new file mode 100644 index 0000000..c270f20 --- /dev/null +++ b/packages/media/src/__contracts__/media-repository.contract.ts @@ -0,0 +1,61 @@ +import { it, expect, beforeEach } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { IMediaRepository } from "../application/repositories/media.repository.interface.js"; +import { mediaFactory } from "../__factories__/media.factory.js"; + +export const mediaRepositoryContract = + defineContractSuite( + "IMediaRepository", + ({ buildSubject }) => { + let repo: IMediaRepository; + + beforeEach(async () => { + mediaFactory.reset(); + repo = await buildSubject(); + }); + + // --- getMedia --- + + it("getMedia returns undefined for a missing id", async () => { + const result = await repo.getMedia("does-not-exist"); + expect(result).toBeUndefined(); + }); + + // --- listMedia --- + + it("listMedia returns empty array when no media exists", async () => { + const result = await repo.listMedia(); + expect(result).toHaveLength(0); + }); + + // --- deleteMedia --- + + it("deleteMedia removes the item so getMedia returns undefined", async () => { + const seed = mediaFactory.build({ id: "contract-1" }); + // @ts-expect-error _store is a test helper on MockMediaRepository + if (typeof repo._store === "function") { + // @ts-expect-error _store is a test helper + await repo._store(seed); + } + await repo.deleteMedia("contract-1"); + const result = await repo.getMedia("contract-1"); + expect(result).toBeUndefined(); + }); + + it("listMedia with limit returns at most limit items", async () => { + // Only verifiable on mock; for real impl this checks the interface + const result = await repo.listMedia({ limit: 0 }); + expect(Array.isArray(result)).toBe(true); + }); + + it("listMedia with offset skips items", async () => { + const result = await repo.listMedia({ offset: 100 }); + expect(Array.isArray(result)).toBe(true); + }); + + it("getMedia returns undefined for empty string id", async () => { + const result = await repo.getMedia(""); + expect(result).toBeUndefined(); + }); + }, + ); diff --git a/packages/media/src/__factories__/index.ts b/packages/media/src/__factories__/index.ts index b5dc1be..628d3d6 100644 --- a/packages/media/src/__factories__/index.ts +++ b/packages/media/src/__factories__/index.ts @@ -1 +1,2 @@ -export { mediaFactory, type Media } from "./media.factory.js"; +export { mediaFactory } from "./media.factory.js"; +export type { Media } from "../entities/models/media.js"; diff --git a/packages/media/src/__factories__/media.factory.ts b/packages/media/src/__factories__/media.factory.ts index 2ddb43f..e21dcdc 100644 --- a/packages/media/src/__factories__/media.factory.ts +++ b/packages/media/src/__factories__/media.factory.ts @@ -1,13 +1,5 @@ import { defineFactory } from "@repo/core-testing/factory"; - -export interface Media { - id: string; - alt: string; - url: string; - filename: string; - mimeType: string; - filesize: number; -} +import type { Media } from "../entities/models/media"; export const mediaFactory = defineFactory(({ sequence }) => ({ id: `media-${sequence}`, diff --git a/packages/media/src/application/repositories/media.repository.interface.ts b/packages/media/src/application/repositories/media.repository.interface.ts new file mode 100644 index 0000000..c474ace --- /dev/null +++ b/packages/media/src/application/repositories/media.repository.interface.ts @@ -0,0 +1,7 @@ +import type { Media } from "../../entities/models/media"; + +export interface IMediaRepository { + getMedia(id: string): Promise; + listMedia(opts?: { limit?: number; offset?: number }): Promise; + deleteMedia(id: string): Promise; +} diff --git a/packages/media/src/application/use-cases/delete-media.use-case.test.ts b/packages/media/src/application/use-cases/delete-media.use-case.test.ts new file mode 100644 index 0000000..2f1d391 --- /dev/null +++ b/packages/media/src/application/use-cases/delete-media.use-case.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { deleteMediaUseCase } from "@/application/use-cases/delete-media.use-case"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import { MediaNotFoundError } from "@/entities/errors/media"; +import { mediaFactory } from "@/__factories__/media.factory"; + +describe("deleteMediaUseCase", () => { + it("deletes media when found", async () => { + const repo = new MockMediaRepository(); + const seed = mediaFactory.build({ id: "m-1" }); + await repo._store(seed); + + const useCase = deleteMediaUseCase(repo); + await useCase({ id: "m-1" }); + + const result = await repo.getMedia("m-1"); + expect(result).toBeUndefined(); + }); + + it("throws MediaNotFoundError when id does not exist", async () => { + const repo = new MockMediaRepository(); + const useCase = deleteMediaUseCase(repo); + + await expect(useCase({ id: "missing" })).rejects.toThrow(MediaNotFoundError); + }); +}); 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 new file mode 100644 index 0000000..26b1d77 --- /dev/null +++ b/packages/media/src/application/use-cases/delete-media.use-case.ts @@ -0,0 +1,14 @@ +import { MediaNotFoundError } from "../../entities/errors/media"; +import type { IMediaRepository } from "../repositories/media.repository.interface"; + +export type IDeleteMediaUseCase = ReturnType; + +export const deleteMediaUseCase = + (mediaRepository: IMediaRepository) => + async (input: { id: string }): Promise => { + const existing = await mediaRepository.getMedia(input.id); + if (!existing) { + throw new MediaNotFoundError(`Media with id "${input.id}" not found`); + } + await mediaRepository.deleteMedia(input.id); + }; 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 new file mode 100644 index 0000000..1c288f4 --- /dev/null +++ b/packages/media/src/application/use-cases/get-media.use-case.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { getMediaUseCase } 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"; + +describe("getMediaUseCase", () => { + it("returns media when found", async () => { + const repo = new MockMediaRepository(); + const seed = mediaFactory.build({ id: "m-1" }); + await repo._store(seed); + + const useCase = getMediaUseCase(repo); + const result = await useCase({ id: "m-1" }); + + expect(result.id).toBe("m-1"); + expect(result.alt).toBe(seed.alt); + }); + + it("throws MediaNotFoundError when id does not exist", async () => { + const repo = new MockMediaRepository(); + const useCase = getMediaUseCase(repo); + + await expect(useCase({ id: "missing" })).rejects.toThrow(MediaNotFoundError); + }); +}); 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 new file mode 100644 index 0000000..5199a68 --- /dev/null +++ b/packages/media/src/application/use-cases/get-media.use-case.ts @@ -0,0 +1,15 @@ +import { MediaNotFoundError } from "../../entities/errors/media"; +import type { Media } from "../../entities/models/media"; +import type { IMediaRepository } from "../repositories/media.repository.interface"; + +export type IGetMediaUseCase = ReturnType; + +export const getMediaUseCase = + (mediaRepository: IMediaRepository) => + async (input: { id: string }): Promise => { + const media = await mediaRepository.getMedia(input.id); + if (!media) { + throw new MediaNotFoundError(`Media with id "${input.id}" not found`); + } + return 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 new file mode 100644 index 0000000..8bf3be6 --- /dev/null +++ b/packages/media/src/application/use-cases/list-media.use-case.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { listMediaUseCase } from "@/application/use-cases/list-media.use-case"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import { mediaFactory } from "@/__factories__/media.factory"; + +describe("listMediaUseCase", () => { + it("returns empty array when no media exists", async () => { + const repo = new MockMediaRepository(); + const useCase = listMediaUseCase(repo); + const result = await useCase(); + expect(result).toHaveLength(0); + }); + + it("returns all media when store has items", async () => { + const repo = new MockMediaRepository(); + await repo._store(mediaFactory.build()); + await repo._store(mediaFactory.build()); + + const useCase = listMediaUseCase(repo); + const result = await useCase(); + expect(result).toHaveLength(2); + }); + + it("respects limit and offset options", async () => { + const repo = new MockMediaRepository(); + for (let i = 0; i < 5; i++) { + await repo._store(mediaFactory.build()); + } + + const useCase = listMediaUseCase(repo); + const result = await useCase({ limit: 2, offset: 1 }); + expect(result).toHaveLength(2); + }); +}); 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 new file mode 100644 index 0000000..fc83edb --- /dev/null +++ b/packages/media/src/application/use-cases/list-media.use-case.ts @@ -0,0 +1,10 @@ +import type { Media } from "../../entities/models/media"; +import type { IMediaRepository } from "../repositories/media.repository.interface"; + +export type IListMediaUseCase = ReturnType; + +export const listMediaUseCase = + (mediaRepository: IMediaRepository) => + async (opts?: { limit?: number; offset?: number }): Promise => { + return mediaRepository.listMedia(opts); + }; diff --git a/packages/media/src/di/bind-production.ts b/packages/media/src/di/bind-production.ts new file mode 100644 index 0000000..6bc350c --- /dev/null +++ b/packages/media/src/di/bind-production.ts @@ -0,0 +1,13 @@ +import type { SanitizedConfig } from "payload"; +import { mediaContainer } from "./container"; +import { MEDIA_SYMBOLS } from "./symbols"; +import { MediaRepository } from "../infrastructure/repositories/media.repository"; + +export function bindProductionMedia(config: SanitizedConfig): void { + if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) { + mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository); + } + mediaContainer + .bind(MEDIA_SYMBOLS.IMediaRepository) + .toConstantValue(new MediaRepository(config)); +} diff --git a/packages/media/src/di/container.test.ts b/packages/media/src/di/container.test.ts new file mode 100644 index 0000000..570c654 --- /dev/null +++ b/packages/media/src/di/container.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mediaContainer } from "@/di/container"; +import { MEDIA_SYMBOLS } from "@/di/symbols"; +import { MediaModule } from "@/di/module"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import type { IMediaRepository } from "@/application/repositories/media.repository.interface"; + +describe("mediaContainer", () => { + beforeEach(() => { + mediaContainer.unbindAll(); + mediaContainer.load(MediaModule); + }); + + afterEach(() => { + mediaContainer.unbindAll(); + }); + + it("resolves IMediaRepository to MockMediaRepository by default binding", () => { + const repo = mediaContainer.get(MEDIA_SYMBOLS.IMediaRepository); + expect(repo).toBeInstanceOf(MockMediaRepository); + }); + + it("resolves IGetMediaController as a function", () => { + const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IGetMediaController); + expect(typeof ctrl).toBe("function"); + }); + + it("resolves IListMediaController as a function", () => { + const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IListMediaController); + expect(typeof ctrl).toBe("function"); + }); + + it("resolves IDeleteMediaController as a function", () => { + const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IDeleteMediaController); + expect(typeof ctrl).toBe("function"); + }); + + it("resolves IGetMediaUseCase as a function", () => { + const uc = mediaContainer.get(MEDIA_SYMBOLS.IGetMediaUseCase); + expect(typeof uc).toBe("function"); + }); + + it("resolves IListMediaUseCase as a function", () => { + const uc = mediaContainer.get(MEDIA_SYMBOLS.IListMediaUseCase); + expect(typeof uc).toBe("function"); + }); + + it("resolves IDeleteMediaUseCase as a function", () => { + const uc = mediaContainer.get(MEDIA_SYMBOLS.IDeleteMediaUseCase); + expect(typeof uc).toBe("function"); + }); +}); diff --git a/packages/media/src/di/container.ts b/packages/media/src/di/container.ts new file mode 100644 index 0000000..7e7ffcc --- /dev/null +++ b/packages/media/src/di/container.ts @@ -0,0 +1,6 @@ +import "reflect-metadata"; +import { Container } from "inversify"; +import { MediaModule } from "./module"; + +export const mediaContainer = new Container({ defaultScope: "Singleton" }); +mediaContainer.load(MediaModule); diff --git a/packages/media/src/di/module.ts b/packages/media/src/di/module.ts new file mode 100644 index 0000000..30ea30d --- /dev/null +++ b/packages/media/src/di/module.ts @@ -0,0 +1,69 @@ +import { ContainerModule, type interfaces } from "inversify"; + +import type { IMediaRepository } from "../application/repositories/media.repository.interface"; +import { MockMediaRepository } from "../infrastructure/repositories/media.repository.mock"; +import { + getMediaUseCase, + type IGetMediaUseCase, +} from "../application/use-cases/get-media.use-case"; +import { + listMediaUseCase, + type IListMediaUseCase, +} from "../application/use-cases/list-media.use-case"; +import { + deleteMediaUseCase, + type IDeleteMediaUseCase, +} from "../application/use-cases/delete-media.use-case"; +import { + getMediaController, + type IGetMediaController, +} from "../interface-adapters/controllers/get-media.controller"; +import { + listMediaController, + type IListMediaController, +} from "../interface-adapters/controllers/list-media.controller"; +import { + deleteMediaController, + type IDeleteMediaController, +} from "../interface-adapters/controllers/delete-media.controller"; +import { MEDIA_SYMBOLS } from "./symbols"; + +export const MediaModule = new ContainerModule((bind: interfaces.Bind) => { + bind(MEDIA_SYMBOLS.IMediaRepository).to(MockMediaRepository); + + bind(MEDIA_SYMBOLS.IGetMediaUseCase).toDynamicValue((ctx) => + getMediaUseCase( + ctx.container.get(MEDIA_SYMBOLS.IMediaRepository), + ), + ); + + bind(MEDIA_SYMBOLS.IListMediaUseCase).toDynamicValue((ctx) => + listMediaUseCase( + ctx.container.get(MEDIA_SYMBOLS.IMediaRepository), + ), + ); + + bind(MEDIA_SYMBOLS.IDeleteMediaUseCase).toDynamicValue((ctx) => + deleteMediaUseCase( + ctx.container.get(MEDIA_SYMBOLS.IMediaRepository), + ), + ); + + bind(MEDIA_SYMBOLS.IGetMediaController).toDynamicValue((ctx) => + getMediaController( + ctx.container.get(MEDIA_SYMBOLS.IGetMediaUseCase), + ), + ); + + bind(MEDIA_SYMBOLS.IListMediaController).toDynamicValue((ctx) => + listMediaController( + ctx.container.get(MEDIA_SYMBOLS.IListMediaUseCase), + ), + ); + + bind(MEDIA_SYMBOLS.IDeleteMediaController).toDynamicValue((ctx) => + deleteMediaController( + ctx.container.get(MEDIA_SYMBOLS.IDeleteMediaUseCase), + ), + ); +}); diff --git a/packages/media/src/di/symbols.ts b/packages/media/src/di/symbols.ts new file mode 100644 index 0000000..eb4f417 --- /dev/null +++ b/packages/media/src/di/symbols.ts @@ -0,0 +1,11 @@ +export const MEDIA_SYMBOLS = { + IMediaRepository: Symbol.for("media:IMediaRepository"), + // Use cases + IGetMediaUseCase: Symbol.for("media:IGetMediaUseCase"), + IListMediaUseCase: Symbol.for("media:IListMediaUseCase"), + IDeleteMediaUseCase: Symbol.for("media:IDeleteMediaUseCase"), + // Controllers + IGetMediaController: Symbol.for("media:IGetMediaController"), + IListMediaController: Symbol.for("media:IListMediaController"), + IDeleteMediaController: Symbol.for("media:IDeleteMediaController"), +} as const; diff --git a/packages/media/src/entities/errors/common.ts b/packages/media/src/entities/errors/common.ts new file mode 100644 index 0000000..18bb07a --- /dev/null +++ b/packages/media/src/entities/errors/common.ts @@ -0,0 +1,5 @@ +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} diff --git a/packages/media/src/entities/errors/media.test.ts b/packages/media/src/entities/errors/media.test.ts new file mode 100644 index 0000000..6d1f722 --- /dev/null +++ b/packages/media/src/entities/errors/media.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { MediaNotFoundError } from "@/entities/errors/media"; + +describe("MediaNotFoundError", () => { + it("is an instance of Error", () => { + const err = new MediaNotFoundError(); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(MediaNotFoundError); + }); + + it("has default message", () => { + const err = new MediaNotFoundError(); + expect(err.message).toBe("Media not found"); + }); + + it("accepts a custom message", () => { + const err = new MediaNotFoundError("Custom message"); + expect(err.message).toBe("Custom message"); + }); +}); diff --git a/packages/media/src/entities/errors/media.ts b/packages/media/src/entities/errors/media.ts new file mode 100644 index 0000000..3628fc3 --- /dev/null +++ b/packages/media/src/entities/errors/media.ts @@ -0,0 +1,5 @@ +export class MediaNotFoundError extends Error { + constructor(message = "Media not found", options?: ErrorOptions) { + super(message, options); + } +} diff --git a/packages/media/src/entities/models/media.test.ts b/packages/media/src/entities/models/media.test.ts new file mode 100644 index 0000000..acf65e2 --- /dev/null +++ b/packages/media/src/entities/models/media.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { mediaSchema } from "@/entities/models/media"; + +describe("mediaSchema", () => { + it("parses a valid media object", () => { + const result = mediaSchema.parse({ + id: "1", + alt: "A photo", + url: "https://cdn.example.com/photo.png", + filename: "photo.png", + mimeType: "image/png", + filesize: 1024, + }); + expect(result.id).toBe("1"); + expect(result.alt).toBe("A photo"); + }); + + it("parses a media object with optional width and height", () => { + const result = mediaSchema.parse({ + id: "2", + alt: "A photo", + url: "https://cdn.example.com/photo.png", + filename: "photo.png", + mimeType: "image/png", + filesize: 2048, + width: 1920, + height: 1080, + }); + expect(result.width).toBe(1920); + expect(result.height).toBe(1080); + }); + + it("parses without width and height (optional)", () => { + const result = mediaSchema.parse({ + id: "3", + alt: "Doc", + url: "https://cdn.example.com/doc.pdf", + filename: "doc.pdf", + mimeType: "application/pdf", + filesize: 512, + }); + expect(result.width).toBeUndefined(); + expect(result.height).toBeUndefined(); + }); + + it("throws for missing required fields", () => { + expect(() => + mediaSchema.parse({ id: "1", alt: "x", url: "x" }), + ).toThrow(); + }); +}); diff --git a/packages/media/src/entities/models/media.ts b/packages/media/src/entities/models/media.ts new file mode 100644 index 0000000..dc36f54 --- /dev/null +++ b/packages/media/src/entities/models/media.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const mediaSchema = z.object({ + id: z.string(), + alt: z.string(), + url: z.string(), + filename: z.string(), + mimeType: z.string(), + filesize: z.number(), + width: z.number().optional(), + height: z.number().optional(), +}); + +export type Media = z.infer; diff --git a/packages/media/src/index.ts b/packages/media/src/index.ts index cb0ff5c..4f20c6f 100644 --- a/packages/media/src/index.ts +++ b/packages/media/src/index.ts @@ -1 +1,4 @@ -export {}; +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"; diff --git a/packages/media/src/infrastructure/repositories/media.repository.mock.test.ts b/packages/media/src/infrastructure/repositories/media.repository.mock.test.ts new file mode 100644 index 0000000..a1684d4 --- /dev/null +++ b/packages/media/src/infrastructure/repositories/media.repository.mock.test.ts @@ -0,0 +1,7 @@ +import { describe } from "vitest"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import { mediaRepositoryContract } from "@/__contracts__/media-repository.contract"; + +describe("MockMediaRepository", () => { + mediaRepositoryContract.run(() => new MockMediaRepository()); +}); diff --git a/packages/media/src/infrastructure/repositories/media.repository.mock.ts b/packages/media/src/infrastructure/repositories/media.repository.mock.ts new file mode 100644 index 0000000..f3d7b12 --- /dev/null +++ b/packages/media/src/infrastructure/repositories/media.repository.mock.ts @@ -0,0 +1,29 @@ +import "reflect-metadata"; +import { injectable } from "inversify"; + +import type { IMediaRepository } from "../../application/repositories/media.repository.interface"; +import type { Media } from "../../entities/models/media"; + +@injectable() +export class MockMediaRepository implements IMediaRepository { + private _media: Media[] = []; + + /** Test helper — seeds the in-memory store directly. */ + async _store(media: Media): Promise { + this._media.push(media); + } + + async getMedia(id: string): Promise { + return this._media.find((m) => m.id === id); + } + + async listMedia(opts?: { limit?: number; offset?: number }): Promise { + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? 50; + return this._media.slice(offset, offset + limit); + } + + async deleteMedia(id: string): Promise { + this._media = this._media.filter((m) => m.id !== id); + } +} diff --git a/packages/media/src/infrastructure/repositories/media.repository.test.ts b/packages/media/src/infrastructure/repositories/media.repository.test.ts new file mode 100644 index 0000000..c7fc184 --- /dev/null +++ b/packages/media/src/infrastructure/repositories/media.repository.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import { MediaRepository } from "@/infrastructure/repositories/media.repository"; +import { mediaRepositoryContract } from "@/__contracts__/media-repository.contract"; +import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; + +// --------------------------------------------------------------------------- +// In-memory Payload stub +// --------------------------------------------------------------------------- + +function buildPayloadStub() { + const store = new Map>(); + + return { + find: vi.fn( + async ({ + limit, + page, + }: { + collection: string; + limit?: number; + page?: number; + overrideAccess?: boolean; + }) => { + let docs = Array.from(store.values()); + const lim = limit ?? 50; + const pg = page ?? 1; + const offset = (pg - 1) * lim; + docs = docs.slice(offset, offset + lim); + return { docs }; + }, + ), + findByID: vi.fn( + async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => { + const doc = store.get(String(id)); + if (!doc) throw new Error(`Not found: ${id}`); + return doc; + }, + ), + delete: vi.fn( + async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => { + store.delete(String(id)); + return { id }; + }, + ), + }; +} + +vi.mock("payload", () => ({ + getPayload: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Contract suite +// --------------------------------------------------------------------------- + +describe("MediaRepository", () => { + describe("contract", () => { + mediaRepositoryContract.run(async () => { + const stub = buildPayloadStub(); + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + return new MediaRepository(stubPayloadConfig); + }); + }); + + // ------------------------------------------------------------------------- + // Impl-specific tests: Payload doc → domain mapping + // ------------------------------------------------------------------------- + + describe("getMedia", () => { + it("returns undefined when Payload throws (not found)", async () => { + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue({ + findByID: vi.fn().mockRejectedValue(new Error("Not found")), + }); + + const repo = new MediaRepository(stubPayloadConfig); + const result = await repo.getMedia("missing-id"); + expect(result).toBeUndefined(); + }); + + it("maps Payload doc fields to domain Media", async () => { + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue({ + findByID: vi.fn().mockResolvedValue({ + id: "p-1", + alt: "Test image", + url: "https://cdn.example.com/test.png", + filename: "test.png", + mimeType: "image/png", + filesize: 2048, + width: 800, + height: 600, + }), + }); + + const repo = new MediaRepository(stubPayloadConfig); + const result = await repo.getMedia("p-1"); + expect(result?.id).toBe("p-1"); + expect(result?.alt).toBe("Test image"); + expect(result?.width).toBe(800); + expect(result?.height).toBe(600); + }); + }); + + describe("listMedia", () => { + it("returns an array of mapped Media docs", async () => { + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue({ + find: vi.fn().mockResolvedValue({ + docs: [ + { + id: "m-1", + alt: "First", + url: "https://cdn.example.com/first.png", + filename: "first.png", + mimeType: "image/png", + filesize: 100, + }, + ], + }), + }); + + const repo = new MediaRepository(stubPayloadConfig); + const result = await repo.listMedia(); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("m-1"); + }); + }); +}); diff --git a/packages/media/src/infrastructure/repositories/media.repository.ts b/packages/media/src/infrastructure/repositories/media.repository.ts new file mode 100644 index 0000000..164ec12 --- /dev/null +++ b/packages/media/src/infrastructure/repositories/media.repository.ts @@ -0,0 +1,76 @@ +import "reflect-metadata"; +import { injectable } from "inversify"; +import { getPayload } from "payload"; +import type { SanitizedConfig } from "payload"; + +import type { IMediaRepository } from "../../application/repositories/media.repository.interface"; +import type { Media } from "../../entities/models/media"; + +type PayloadMediaDoc = { + id: string | number; + alt?: string | null; + url?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; +}; + +function mapDoc(doc: PayloadMediaDoc): Media { + return { + id: String(doc.id), + alt: doc.alt ?? "", + url: doc.url ?? "", + filename: doc.filename ?? "", + mimeType: doc.mimeType ?? "", + filesize: doc.filesize ?? 0, + ...(doc.width != null && { width: doc.width }), + ...(doc.height != null && { height: doc.height }), + }; +} + +@injectable() +export class MediaRepository implements IMediaRepository { + private config: SanitizedConfig; + + constructor(config: SanitizedConfig) { + this.config = config; + } + + async getMedia(id: string): Promise { + const payload = await getPayload({ config: this.config }); + try { + const doc = await payload.findByID({ + collection: "media", + id, + overrideAccess: true, + }); + return mapDoc(doc as PayloadMediaDoc); + } catch { + return undefined; + } + } + + async listMedia(opts?: { limit?: number; offset?: number }): Promise { + const payload = await getPayload({ config: this.config }); + const result = await payload.find({ + collection: "media", + limit: opts?.limit ?? 50, + page: opts?.offset + ? Math.floor(opts.offset / (opts.limit ?? 50)) + 1 + : 1, + overrideAccess: true, + }); + return result.docs.map((d) => mapDoc(d as PayloadMediaDoc)); + } + + async deleteMedia(id: string): Promise { + const payload = await getPayload({ config: this.config }); + await payload.delete({ + collection: "media", + id, + overrideAccess: true, + }); + } +} diff --git a/packages/media/src/integrations/api/index.ts b/packages/media/src/integrations/api/index.ts new file mode 100644 index 0000000..32f5024 --- /dev/null +++ b/packages/media/src/integrations/api/index.ts @@ -0,0 +1,2 @@ +export { mediaRouter } from "./router"; +export type { MediaRouter } from "./router"; diff --git a/packages/media/src/integrations/api/router.ts b/packages/media/src/integrations/api/router.ts new file mode 100644 index 0000000..3f53114 --- /dev/null +++ b/packages/media/src/integrations/api/router.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { router, publicProcedure } from "@repo/core-shared/trpc/init"; +import { mediaContainer } from "../../di/container"; +import { MEDIA_SYMBOLS } from "../../di/symbols"; +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"; + +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); + }), +}); + +export type MediaRouter = typeof mediaRouter; diff --git a/packages/media/src/interface-adapters/controllers/delete-media.controller.test.ts b/packages/media/src/interface-adapters/controllers/delete-media.controller.test.ts new file mode 100644 index 0000000..e17aa99 --- /dev/null +++ b/packages/media/src/interface-adapters/controllers/delete-media.controller.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { deleteMediaController } from "@/interface-adapters/controllers/delete-media.controller"; +import { deleteMediaUseCase } from "@/application/use-cases/delete-media.use-case"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import { MediaNotFoundError } from "@/entities/errors/media"; +import { InputParseError } from "@/entities/errors/common"; +import { mediaFactory } from "@/__factories__/media.factory"; + +describe("deleteMediaController", () => { + it("deletes media successfully", async () => { + const repo = new MockMediaRepository(); + const seed = mediaFactory.build({ id: "m-1" }); + await repo._store(seed); + + const useCase = deleteMediaUseCase(repo); + const controller = deleteMediaController(useCase); + + await expect(controller({ id: "m-1" })).resolves.toBeUndefined(); + const result = await repo.getMedia("m-1"); + expect(result).toBeUndefined(); + }); + + it("throws InputParseError when id is missing", async () => { + const repo = new MockMediaRepository(); + const useCase = deleteMediaUseCase(repo); + const controller = deleteMediaController(useCase); + + await expect(controller({})).rejects.toThrow(InputParseError); + }); + + it("throws MediaNotFoundError when media does not exist", async () => { + const repo = new MockMediaRepository(); + const useCase = deleteMediaUseCase(repo); + const controller = deleteMediaController(useCase); + + await expect(controller({ id: "missing" })).rejects.toThrow(MediaNotFoundError); + }); +}); diff --git a/packages/media/src/interface-adapters/controllers/delete-media.controller.ts b/packages/media/src/interface-adapters/controllers/delete-media.controller.ts new file mode 100644 index 0000000..4c98e04 --- /dev/null +++ b/packages/media/src/interface-adapters/controllers/delete-media.controller.ts @@ -0,0 +1,19 @@ +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), +}); + +export type IDeleteMediaController = ReturnType; + +export const deleteMediaController = + (deleteMediaUseCase: IDeleteMediaUseCase) => + async (input: Partial>): Promise => { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid delete-media input", { cause: parsed.error }); + } + return deleteMediaUseCase(parsed.data); + }; diff --git a/packages/media/src/interface-adapters/controllers/get-media.controller.test.ts b/packages/media/src/interface-adapters/controllers/get-media.controller.test.ts new file mode 100644 index 0000000..6c2be3c --- /dev/null +++ b/packages/media/src/interface-adapters/controllers/get-media.controller.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { getMediaController } from "@/interface-adapters/controllers/get-media.controller"; +import { getMediaUseCase } from "@/application/use-cases/get-media.use-case"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import { MediaNotFoundError } from "@/entities/errors/media"; +import { InputParseError } from "@/entities/errors/common"; +import { mediaFactory } from "@/__factories__/media.factory"; + +describe("getMediaController", () => { + it("returns media on valid input", async () => { + const repo = new MockMediaRepository(); + const seed = mediaFactory.build({ id: "m-1" }); + await repo._store(seed); + + const useCase = getMediaUseCase(repo); + const controller = getMediaController(useCase); + + const result = await controller({ id: "m-1" }); + expect(result.id).toBe("m-1"); + }); + + it("throws InputParseError when id is missing", async () => { + const repo = new MockMediaRepository(); + const useCase = getMediaUseCase(repo); + const controller = getMediaController(useCase); + + await expect(controller({})).rejects.toThrow(InputParseError); + }); + + it("throws InputParseError when id is empty string", async () => { + const repo = new MockMediaRepository(); + const useCase = getMediaUseCase(repo); + const controller = getMediaController(useCase); + + await expect(controller({ id: "" })).rejects.toThrow(InputParseError); + }); + + it("throws MediaNotFoundError when media does not exist", async () => { + const repo = new MockMediaRepository(); + const useCase = getMediaUseCase(repo); + const controller = getMediaController(useCase); + + await expect(controller({ id: "missing" })).rejects.toThrow(MediaNotFoundError); + }); +}); diff --git a/packages/media/src/interface-adapters/controllers/get-media.controller.ts b/packages/media/src/interface-adapters/controllers/get-media.controller.ts new file mode 100644 index 0000000..1a5a1da --- /dev/null +++ b/packages/media/src/interface-adapters/controllers/get-media.controller.ts @@ -0,0 +1,20 @@ +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"; + +const inputSchema = z.object({ + id: z.string().min(1), +}); + +export type IGetMediaController = ReturnType; + +export const getMediaController = + (getMediaUseCase: IGetMediaUseCase) => + async (input: Partial>): Promise => { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-media input", { cause: parsed.error }); + } + return getMediaUseCase(parsed.data); + }; diff --git a/packages/media/src/interface-adapters/controllers/list-media.controller.test.ts b/packages/media/src/interface-adapters/controllers/list-media.controller.test.ts new file mode 100644 index 0000000..250dc94 --- /dev/null +++ b/packages/media/src/interface-adapters/controllers/list-media.controller.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { listMediaController } from "@/interface-adapters/controllers/list-media.controller"; +import { listMediaUseCase } from "@/application/use-cases/list-media.use-case"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import { mediaFactory } from "@/__factories__/media.factory"; + +describe("listMediaController", () => { + it("returns empty array when no media exists", async () => { + const repo = new MockMediaRepository(); + const useCase = listMediaUseCase(repo); + const controller = listMediaController(useCase); + + const result = await controller({}); + expect(result).toHaveLength(0); + }); + + it("returns all media", async () => { + const repo = new MockMediaRepository(); + await repo._store(mediaFactory.build()); + await repo._store(mediaFactory.build()); + + const useCase = listMediaUseCase(repo); + const controller = listMediaController(useCase); + + const result = await controller({}); + expect(result).toHaveLength(2); + }); + + it("passes limit and offset to use case", async () => { + const repo = new MockMediaRepository(); + for (let i = 0; i < 5; i++) { + await repo._store(mediaFactory.build()); + } + + const useCase = listMediaUseCase(repo); + const controller = listMediaController(useCase); + + const result = await controller({ limit: 2, offset: 1 }); + expect(result).toHaveLength(2); + }); +}); diff --git a/packages/media/src/interface-adapters/controllers/list-media.controller.ts b/packages/media/src/interface-adapters/controllers/list-media.controller.ts new file mode 100644 index 0000000..edd8d55 --- /dev/null +++ b/packages/media/src/interface-adapters/controllers/list-media.controller.ts @@ -0,0 +1,21 @@ +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"; + +const inputSchema = z.object({ + limit: z.number().optional(), + offset: z.number().optional(), +}); + +export type IListMediaController = ReturnType; + +export const listMediaController = + (listMediaUseCase: IListMediaUseCase) => + async (input: Partial>): Promise => { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid list-media input", { cause: parsed.error }); + } + return listMediaUseCase(parsed.data); + }; diff --git a/packages/media/tests/media.feature.test.ts b/packages/media/tests/media.feature.test.ts new file mode 100644 index 0000000..7b03e21 --- /dev/null +++ b/packages/media/tests/media.feature.test.ts @@ -0,0 +1,83 @@ +// Feature-level test: exercises the full slice +// use case factory chain → mock repo +// Tests the full dependency chain via direct injection (no container rebinding). + +import { describe, expect, it } from "vitest"; +import { MockMediaRepository } from "../src/infrastructure/repositories/media.repository.mock"; +import { getMediaUseCase } from "../src/application/use-cases/get-media.use-case"; +import { listMediaUseCase } from "../src/application/use-cases/list-media.use-case"; +import { deleteMediaUseCase } from "../src/application/use-cases/delete-media.use-case"; +import { getMediaController } from "../src/interface-adapters/controllers/get-media.controller"; +import { listMediaController } from "../src/interface-adapters/controllers/list-media.controller"; +import { deleteMediaController } from "../src/interface-adapters/controllers/delete-media.controller"; +import { MediaNotFoundError } from "../src/entities/errors/media"; +import { mediaFactory } from "../src/__factories__/media.factory"; + +describe("media feature: end-to-end via direct injection", () => { + function buildChain() { + const repo = new MockMediaRepository(); + const getUC = getMediaUseCase(repo); + const listUC = listMediaUseCase(repo); + const deleteUC = deleteMediaUseCase(repo); + const getCtrl = getMediaController(getUC); + const listCtrl = listMediaController(listUC); + const deleteCtrl = deleteMediaController(deleteUC); + return { repo, getCtrl, listCtrl, deleteCtrl }; + } + + it("listMedia returns empty array initially", async () => { + const { listCtrl } = buildChain(); + const result = await listCtrl({}); + expect(result).toHaveLength(0); + }); + + it("stores media and retrieves it by id", async () => { + const { repo, getCtrl } = buildChain(); + const seed = mediaFactory.build({ id: "feat-1" }); + await repo._store(seed); + + const result = await getCtrl({ id: "feat-1" }); + expect(result.id).toBe("feat-1"); + expect(result.alt).toBe(seed.alt); + }); + + it("listMedia returns all stored media", async () => { + const { repo, listCtrl } = buildChain(); + await repo._store(mediaFactory.build()); + await repo._store(mediaFactory.build()); + await repo._store(mediaFactory.build()); + + const result = await listCtrl({}); + expect(result).toHaveLength(3); + }); + + it("deleteMedia removes media so it can no longer be retrieved", async () => { + const { repo, getCtrl, deleteCtrl } = buildChain(); + const seed = mediaFactory.build({ id: "feat-del" }); + await repo._store(seed); + + await deleteCtrl({ id: "feat-del" }); + + await expect(getCtrl({ id: "feat-del" })).rejects.toThrow(MediaNotFoundError); + }); + + it("getMediaController throws MediaNotFoundError for missing id", async () => { + const { getCtrl } = buildChain(); + await expect(getCtrl({ id: "does-not-exist" })).rejects.toBeInstanceOf(MediaNotFoundError); + }); + + it("deleteMediaController throws MediaNotFoundError for missing id", async () => { + const { deleteCtrl } = buildChain(); + await expect(deleteCtrl({ id: "does-not-exist" })).rejects.toBeInstanceOf(MediaNotFoundError); + }); + + it("listMedia with limit respects pagination", async () => { + const { repo, listCtrl } = buildChain(); + for (let i = 0; i < 5; i++) { + await repo._store(mediaFactory.build()); + } + + const result = await listCtrl({ limit: 3 }); + expect(result).toHaveLength(3); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7f1736..8e3fedd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,6 +383,9 @@ importers: '@repo/marketing-pages': specifier: workspace:* version: link:../marketing-pages + '@repo/media': + specifier: workspace:* + version: link:../media '@repo/navigation': specifier: workspace:* version: link:../navigation @@ -708,9 +711,24 @@ importers: packages/media: dependencies: + '@repo/core-shared': + specifier: workspace:* + version: link:../core-shared + '@trpc/server': + specifier: ^11.0.0 + version: 11.16.0(typescript@5.9.3) + inversify: + specifier: ^6.2.0 + version: 6.2.2(reflect-metadata@0.2.2) payload: specifier: ^3.14.0 version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + zod: + specifier: ^3.24.0 + version: 3.25.76 devDependencies: '@repo/core-eslint': specifier: workspace:* @@ -721,9 +739,12 @@ importers: '@repo/core-typescript': specifier: workspace:* version: link:../core-typescript + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 vitest: specifier: ^3.1.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) packages/navigation: dependencies: @@ -8970,7 +8991,7 @@ snapshots: '@types/busboy@1.5.4': dependencies: - '@types/node': 25.5.2 + '@types/node': 22.19.17 '@types/chai@5.2.3': dependencies: @@ -9028,12 +9049,13 @@ snapshots: '@types/node@25.5.2': dependencies: undici-types: 7.18.2 + optional: true '@types/parse-json@4.0.2': {} '@types/pg@8.10.2': dependencies: - '@types/node': 25.5.2 + '@types/node': 22.19.17 pg-protocol: 1.13.0 pg-types: 4.1.0 @@ -9072,7 +9094,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 22.19.17 '@types/yargs-parser@21.0.3': {} @@ -10360,7 +10382,7 @@ snapshots: happy-dom@20.8.9: dependencies: - '@types/node': 25.5.2 + '@types/node': 22.19.17 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -12701,7 +12723,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} + undici-types@7.18.2: + optional: true undici@7.24.4: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index c45df87..b097624 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,6 +20,8 @@ "@repo/auth/api": ["packages/auth/src/integrations/api/router.ts"], "@repo/media": ["packages/media/src/index.ts"], "@repo/media/cms": ["packages/media/src/integrations/cms/index.ts"], + "@repo/media/api": ["packages/media/src/integrations/api/index.ts"], + "@repo/media/di/bind-production": ["packages/media/src/di/bind-production.ts"], "@repo/marketing-pages": ["packages/marketing-pages/src/index.ts"], "@repo/marketing-pages/cms": ["packages/marketing-pages/src/integrations/cms/index.ts"], "@repo/marketing-pages/api": ["packages/marketing-pages/src/integrations/api/router.ts"],