refactor(navigation): unify use-case I/O schemas + presenter + feature error map

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
This commit is contained in:
2026-05-06 15:23:30 +02:00
parent f4adf315af
commit 27c79e6e1f
11 changed files with 180 additions and 20 deletions

View File

@@ -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"

View File

@@ -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);
});
});

View File

@@ -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<typeof getHeaderInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const getHeaderOutputSchema = headerSchema;
export type GetHeaderOutput = z.infer<typeof getHeaderOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IGetHeaderUseCase = ReturnType<typeof getHeaderUseCase>;
export const getHeaderUseCase =
(headerRepository: IHeaderRepository) =>
async (): Promise<Header> => {
return headerRepository.getHeader();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (_input: GetHeaderInput): Promise<GetHeaderOutput> => {
const header = await headerRepository.getHeader();
if (!header) {
throw new HeaderNotFoundError("Header global not found");
}
return getHeaderOutputSchema.parse(header);
};

View File

@@ -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";

View File

@@ -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"],
]),
);

View File

@@ -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<string, never>);
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");
}
});
});

View File

@@ -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<IGetHeaderController>(
NAVIGATION_SYMBOLS.IGetHeaderController,
);
return ctrl();
}),
header: navigationProcedure
.input(getHeaderInputSchema)
.query(({ input }) => {
const ctrl = navigationContainer.get<IGetHeaderController>(
NAVIGATION_SYMBOLS.IGetHeaderController,
);
return ctrl(input);
}),
});
export type NavigationRouter = typeof navigationRouter;

View File

@@ -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");

View File

@@ -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<typeof getHeaderController>;
export const getHeaderController =
(getHeaderUseCase: IGetHeaderUseCase) =>
async (): Promise<Header> => {
return getHeaderUseCase();
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
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);
};

View File

@@ -0,0 +1 @@
export { headerQuery } from "./query";