From 27c79e6e1f4c5830817d6f0cc463ba66ac796c9b Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 15:23:30 +0200 Subject: [PATCH] refactor(navigation): 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): - getHeader use case: input z.object({}).strict() (R5); output = headerSchema parsed at runtime. - getHeader controller: unknown input + identity presenter. - New integrations/api/procedures.ts with navigationProcedure ([InputParseError → BAD_REQUEST], [HeaderNotFoundError → NOT_FOUND]). - Router uses navigationProcedure + .input(getHeaderInputSchema). - src/index.ts: remove headerQuery; export schemas + IUseCase/Controller aliases. - src/ui/index.ts (NEW); 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 --- .../2026-05-06-input-output-unification.md | 15 ++++ packages/navigation/package.json | 1 + .../use-cases/get-header.use-case.test.ts | 12 ++- .../use-cases/get-header.use-case.ts | 23 +++++- packages/navigation/src/index.ts | 15 +++- .../src/integrations/api/procedures.ts | 12 +++ .../src/integrations/api/router.test.ts | 77 ++++++++++++++++++- .../navigation/src/integrations/api/router.ts | 21 +++-- .../controllers/get-header.controller.test.ts | 2 +- .../controllers/get-header.controller.ts | 21 ++++- packages/navigation/src/ui/index.ts | 1 + 11 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 packages/navigation/src/integrations/api/procedures.ts create mode 100644 packages/navigation/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 7a7708f..b27bbc8 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 @@ -23,6 +23,8 @@ doc-update items so docs are written once for the post-Plan-9 state. - packages/blog/src/ui/index.ts — re-exports articleBySlugQuery and listArticlesQuery from ./query (moved from feature root index) - packages/marketing-pages/src/integrations/api/procedures.ts — marketingPagesProcedure with feature error map (InputParseError → BAD_REQUEST, PageNotFoundError → NOT_FOUND) - 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) ## 2. Files modified @@ -58,6 +60,12 @@ doc-update items so docs are written once for the post-Plan-9 state. - packages/marketing-pages/src/index.ts — removed pageBySlugQuery/siteSettingsQuery re-exports; added schemas + types + IUseCase/IController aliases - packages/marketing-pages/package.json — added ./ui subpath export - All affected marketing-pages use-case + controller tests updated for new contracts +- packages/navigation/src/application/use-cases/get-header.use-case.ts — getHeaderInputSchema (z.object({}).strict()) + getHeaderOutputSchema (= headerSchema); _input parameter; output.parse; types exported +- packages/navigation/src/interface-adapters/controllers/get-header.controller.ts — identity presenter; unknown input; imports getHeaderInputSchema from use-case; ReturnType return type +- packages/navigation/src/integrations/api/router.ts — uses navigationProcedure + .input(getHeaderInputSchema); ctrl called with input +- 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 ## 3. Pattern changes (code-level) @@ -65,16 +73,19 @@ doc-update items so docs are written once for the post-Plan-9 state. auth migrated: all 3 use cases. signIn and signUp export xInputSchema + xOutputSchema + types; signOut exports xInputSchema only (void output). All non-void use cases end with `xOutputSchema.parse(result)` before returning. blog migrated: all 3 use cases. getArticles, createArticle, getArticleBySlug each export xInputSchema + xOutputSchema + XInput + XOutput types. getArticlesInputSchema narrows status to articleStatusSchema (not loose string). All 3 end with `xOutputSchema.parse(result)` before returning. 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)`. ### 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. ### 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). ## 4. Error-middleware adoption @@ -89,11 +100,13 @@ marketing-pages migrated: marketingPagesProcedure in procedures.ts wraps defineE auth: `./ui` subpath added to package.json exports; `src/ui/index.ts` placeholder created (auth has no query builders — all procedures are mutations). blog: `./ui` subpath added to package.json exports; `src/ui/index.ts` created re-exporting articleBySlugQuery and listArticlesQuery from ./query. 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). ### 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. ## 6. Test additions @@ -101,11 +114,13 @@ marketing-pages: root `src/index.ts` removed pageBySlugQuery/siteSettingsQuery r auth: signIn and signUp each have 2 new R25 tests — one verifying that a malformed service response throws (Zod parse error), one verifying the output schema parses a valid shape. signOut is void — no R25 test. blog: getArticles, createArticle, getArticleBySlug each have 2 new R25 tests — one verifying that a repository returning a malformed object throws ZodError (instanceof), one verifying the output schema parses a valid shape. 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). ### 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). ### 6.3 R27/R28 — presenter shape tests diff --git a/packages/navigation/package.json b/packages/navigation/package.json index 9f7b0ea..f79478d 100644 --- a/packages/navigation/package.json +++ b/packages/navigation/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/router.ts", "./di/bind-production": "./src/di/bind-production.ts" diff --git a/packages/navigation/src/application/use-cases/get-header.use-case.test.ts b/packages/navigation/src/application/use-cases/get-header.use-case.test.ts index 0dd8f66..0201328 100644 --- a/packages/navigation/src/application/use-cases/get-header.use-case.test.ts +++ b/packages/navigation/src/application/use-cases/get-header.use-case.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case"; import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; @@ -6,8 +7,17 @@ describe("getHeaderUseCase", () => { it("returns the seeded header items", async () => { const repo = new MockHeaderRepository(); const useCase = getHeaderUseCase(repo); - const result = await useCase(); + const result = await useCase({}); expect(result.items.length).toBeGreaterThan(0); expect(result.items[0]?.label).toBe("Home"); }); + + it("(R25) throws ZodError when repository returns malformed header", async () => { + const malformedRepo = { + getHeader: async () => + ({ items: [{ label: "", href: "/", external: false }] }) as never, + }; + const useCase = getHeaderUseCase(malformedRepo); + await expect(useCase({})).rejects.toBeInstanceOf(ZodError); + }); }); diff --git a/packages/navigation/src/application/use-cases/get-header.use-case.ts b/packages/navigation/src/application/use-cases/get-header.use-case.ts index 4da6487..8bb203a 100644 --- a/packages/navigation/src/application/use-cases/get-header.use-case.ts +++ b/packages/navigation/src/application/use-cases/get-header.use-case.ts @@ -1,10 +1,27 @@ -import type { Header } from "../../entities/models/header"; +import { z } from "zod"; + +import { HeaderNotFoundError } from "../../entities/errors/header"; +import { headerSchema } from "../../entities/models/header"; import type { IHeaderRepository } from "../repositories/header.repository.interface"; +// ── Input ──────────────────────────────────────────────────────────────── +export const getHeaderInputSchema = z.object({}).strict(); +export type GetHeaderInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const getHeaderOutputSchema = headerSchema; +export type GetHeaderOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── export type IGetHeaderUseCase = ReturnType; export const getHeaderUseCase = (headerRepository: IHeaderRepository) => - async (): Promise
=> { - return headerRepository.getHeader(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (_input: GetHeaderInput): Promise => { + const header = await headerRepository.getHeader(); + if (!header) { + throw new HeaderNotFoundError("Header global not found"); + } + return getHeaderOutputSchema.parse(header); }; diff --git a/packages/navigation/src/index.ts b/packages/navigation/src/index.ts index 1d50c0b..27ba255 100644 --- a/packages/navigation/src/index.ts +++ b/packages/navigation/src/index.ts @@ -1,3 +1,16 @@ export type { Header, HeaderItem } from "./entities/models/header"; export type { NavigationRouter } from "./integrations/api/router"; -export { headerQuery } from "./ui/query"; +export { HeaderNotFoundError } from "./entities/errors/header"; +export { InputParseError } from "./entities/errors/common"; + +// Use case schemas + types (Plan 9 R18) +export { + getHeaderInputSchema, + getHeaderOutputSchema, + type GetHeaderInput, + type GetHeaderOutput, + type IGetHeaderUseCase, +} from "./application/use-cases/get-header.use-case"; + +// Controller type aliases +export type { IGetHeaderController } from "./interface-adapters/controllers/get-header.controller"; diff --git a/packages/navigation/src/integrations/api/procedures.ts b/packages/navigation/src/integrations/api/procedures.ts new file mode 100644 index 0000000..90ac7d2 --- /dev/null +++ b/packages/navigation/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 { HeaderNotFoundError } from "../../entities/errors/header"; +import { InputParseError } from "../../entities/errors/common"; + +export const navigationProcedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [HeaderNotFoundError, "NOT_FOUND"], + ]), +); diff --git a/packages/navigation/src/integrations/api/router.test.ts b/packages/navigation/src/integrations/api/router.test.ts index dd03bd1..8f906d8 100644 --- a/packages/navigation/src/integrations/api/router.test.ts +++ b/packages/navigation/src/integrations/api/router.test.ts @@ -1,7 +1,23 @@ -import { describe, expect, it } from "vitest"; -import { navigationRouter } from "./router"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { injectable } from "inversify"; +import { navigationContainer } from "@/di/container"; +import { NavigationModule } from "@/di/module"; +import { NAVIGATION_SYMBOLS } from "@/di/symbols"; +import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case"; +import { getHeaderController } from "@/interface-adapters/controllers/get-header.controller"; +import { navigationRouter } from "@/integrations/api/router"; describe("navigationRouter", () => { + beforeEach(() => { + navigationContainer.unbindAll(); + navigationContainer.load(NavigationModule); + }); + + afterEach(() => { + navigationContainer.unbindAll(); + }); + it("exposes header procedure", () => { const names = Object.keys(navigationRouter._def.procedures); expect(names).toContain("header"); @@ -9,7 +25,62 @@ describe("navigationRouter", () => { it("header returns 3 items", async () => { const caller = navigationRouter.createCaller({}); - const result = await caller.header(); + const result = await caller.header({}); expect(result.items).toHaveLength(3); }); }); + +describe("navigationRouter (R26 error mapping)", () => { + beforeEach(() => { + navigationContainer.unbindAll(); + navigationContainer.load(NavigationModule); + }); + + afterEach(() => { + navigationContainer.unbindAll(); + }); + + it("translates InputParseError → BAD_REQUEST when extra fields are passed", async () => { + const caller = navigationRouter.createCaller({}); + try { + await caller.header({ unexpected: "field" } as unknown as Record); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("BAD_REQUEST"); + } + }); + + it("translates HeaderNotFoundError → NOT_FOUND when repository returns null", async () => { + @injectable() + class NullHeaderRepository { + async getHeader() { + return null as never; + } + } + + navigationContainer.unbindAll(); + navigationContainer + .bind(NAVIGATION_SYMBOLS.IHeaderRepository) + .to(NullHeaderRepository); + navigationContainer + .bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase) + .toDynamicValue((ctx) => + getHeaderUseCase(ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository)), + ); + navigationContainer + .bind(NAVIGATION_SYMBOLS.IGetHeaderController) + .toDynamicValue((ctx) => + getHeaderController(ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase)), + ); + + const caller = navigationRouter.createCaller({}); + try { + await caller.header({}); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } + }); +}); diff --git a/packages/navigation/src/integrations/api/router.ts b/packages/navigation/src/integrations/api/router.ts index 1eecd4a..16ccb76 100644 --- a/packages/navigation/src/integrations/api/router.ts +++ b/packages/navigation/src/integrations/api/router.ts @@ -1,15 +1,22 @@ -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; +import { router } from "@repo/core-shared/trpc/init"; + import { navigationContainer } from "../../di/container"; import { NAVIGATION_SYMBOLS } from "../../di/symbols"; + +import { getHeaderInputSchema } from "../../application/use-cases/get-header.use-case"; import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller"; +import { navigationProcedure } from "./procedures"; + export const navigationRouter = router({ - header: publicProcedure.query(() => { - const ctrl = navigationContainer.get( - NAVIGATION_SYMBOLS.IGetHeaderController, - ); - return ctrl(); - }), + header: navigationProcedure + .input(getHeaderInputSchema) + .query(({ input }) => { + const ctrl = navigationContainer.get( + NAVIGATION_SYMBOLS.IGetHeaderController, + ); + return ctrl(input); + }), }); export type NavigationRouter = typeof navigationRouter; diff --git a/packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts b/packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts index 0e96970..a028a49 100644 --- a/packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts +++ b/packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts @@ -9,7 +9,7 @@ describe("getHeaderController", () => { const useCase = getHeaderUseCase(repo); const controller = getHeaderController(useCase); - const result = await controller(); + const result = await controller({}); expect(result.items.length).toBeGreaterThan(0); expect(result.items[0]?.label).toBe("Home"); diff --git a/packages/navigation/src/interface-adapters/controllers/get-header.controller.ts b/packages/navigation/src/interface-adapters/controllers/get-header.controller.ts index c8786c7..dadab55 100644 --- a/packages/navigation/src/interface-adapters/controllers/get-header.controller.ts +++ b/packages/navigation/src/interface-adapters/controllers/get-header.controller.ts @@ -1,10 +1,23 @@ -import type { Header } from "../../entities/models/header"; -import type { IGetHeaderUseCase } from "../../application/use-cases/get-header.use-case"; +import { InputParseError } from "../../entities/errors/common"; +import { + getHeaderInputSchema, + type GetHeaderOutput, + type IGetHeaderUseCase, +} from "../../application/use-cases/get-header.use-case"; + +function presenter(value: GetHeaderOutput) { + return value; +} export type IGetHeaderController = ReturnType; export const getHeaderController = (getHeaderUseCase: IGetHeaderUseCase) => - async (): Promise
=> { - return getHeaderUseCase(); + async (input: unknown): Promise> => { + const parsed = getHeaderInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-header input", { cause: parsed.error }); + } + const result = await getHeaderUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/navigation/src/ui/index.ts b/packages/navigation/src/ui/index.ts new file mode 100644 index 0000000..ad7dc5f --- /dev/null +++ b/packages/navigation/src/ui/index.ts @@ -0,0 +1 @@ +export { headerQuery } from "./query";