Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
# AGENTS.md — navigation
Header global for main site navigation. Provides the Header Payload global and tRPC procedures for dynamic navigation content.
## Overview
`@repo/navigation` owns: Header and HeaderItem domain models, navigation-scoped errors, the `IHeaderRepository` interface, one use case, one controller, a real Payload-backed repository, and the tRPC `navigationRouter`. The `headerQuery` React Query builder lives in `./ui`.
## Layer responsibilities
| Layer | Key files |
| ---------------------------------- | ------------------------------------------------------------------------------------- |
| **entities/models** | `header.ts``Header`, `HeaderItem` Zod schemas + types |
| **entities/errors** | `header.ts` (HeaderNotFoundError), `common.ts` (InputParseError) |
| **application/use-cases** | `get-header.use-case.ts` — factory function + exported schemas |
| **application/repositories** | `header.repository.interface.ts``IHeaderRepository` |
| **infrastructure/repositories** | `header.repository.ts` (real Payload-backed), `header.repository.mock.ts` (in-memory) |
| **interface-adapters/controllers** | `get-header.controller.ts` — one file per use case |
| **di** | `symbols.ts` (NAVIGATION_SYMBOLS), `module.ts`, `container.ts`, `bind-production.ts` |
| **integrations/api** | `procedures.ts` (navigationProcedure), `router.ts` (navigationRouter) |
| **integrations/cms** | `globals/header.ts` — Payload Header GlobalConfig |
| **ui** | `src/ui/index.ts` — re-exports `headerQuery` |
## Public exports
| Subpath | Contents |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.` | `Header`, `HeaderItem` types; `HeaderNotFoundError`, `InputParseError`; `getHeaderInputSchema`, `getHeaderOutputSchema`, `GetHeaderInput`, `GetHeaderOutput`, `IGetHeaderUseCase`; `IGetHeaderController` type alias; `NavigationRouter` type |
| `./ui` | `headerQuery` — React Query option builder |
| `./api` | `navigationRouter` (tRPC router) |
| `./cms` | Payload Header global definition |
| `./di/bind-production` | `bindProductionNavigation(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
| `./di/bind-dev-seed` | `bindDevSeedNavigation(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
## Use-case + controller patterns
See `CLAUDE.md` Key Conventions and `docs/architecture/overview.md` for the canonical factory templates.
### Use case
| Use case | Input schema | Output schema | Notes |
| ------------------ | ------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `getHeaderUseCase` | `getHeaderInputSchema``z.object({}).strict()` (void input) | `getHeaderOutputSchema``= headerSchema` | Takes `_input: GetHeaderInput`; throws `HeaderNotFoundError` when repository returns falsy; ends with `getHeaderOutputSchema.parse(header)` |
### Controller
`getHeaderController` uses an identity presenter — `function presenter(value: GetHeaderOutput) { return value; }` — and returns `Promise<ReturnType<typeof presenter>>`. Accepts `unknown` input and `safeParse` with `getHeaderInputSchema`, throwing `InputParseError` on failure.
## Errors → tRPC codes
| Error class | tRPC code | Thrown by |
| --------------------- | ------------- | ---------------------------------------------------------------------------------- |
| `InputParseError` | `BAD_REQUEST` | controller (safeParse failure; also triggers on `strict()` rejecting unknown keys) |
| `HeaderNotFoundError` | `NOT_FOUND` | `getHeaderUseCase` when repository returns falsy |
Defined in `src/integrations/api/procedures.ts` via `navigationProcedure = t.procedure.use(defineErrorMiddleware([...]))`.
## Tests
- **Factories:** `src/__factories__/header.factory.ts`, `src/__factories__/nav-item.factory.ts`
- **Contract suite:** `src/__contracts__/header-repository.contract.ts` — runs against mock and real `HeaderRepository`
- **Unit tests:** colocated `*.test.ts` next to each source file
- **R25** (output validation): `get-header.use-case.test.ts` has a test using an inline malformed repository mock (e.g., `{ items: [{ label: "", href: "/", external: false }] }`, label failing `min(1)`) to assert `.rejects.toBeInstanceOf(ZodError)`.
- **R26** (router error mapping): `router.test.ts` asserts `BAD_REQUEST` when input has extra unknown keys (strict mode rejection → InputParseError), and `NOT_FOUND` via an inline `NullHeaderRepository` rebind causing `HeaderNotFoundError`.
```bash
pnpm test --filter @repo/navigation
pnpm test --filter @repo/navigation -- --watch
```
See `docs/guides/tdd-workflow.md` for the full cycle.
## Directory structure
```
src/
entities/
models/
header.ts # Header, HeaderItem schemas + types
errors/
header.ts # HeaderNotFoundError
common.ts # InputParseError
application/
repositories/
header.repository.interface.ts
use-cases/
get-header.use-case.ts
infrastructure/
repositories/
header.repository.ts # real Payload-backed
header.repository.mock.ts
interface-adapters/
controllers/
get-header.controller.ts
integrations/
api/
procedures.ts # navigationProcedure
router.ts # navigationRouter
cms/
globals/
header.ts
index.ts
di/
symbols.ts
module.ts
container.ts
bind-production.ts
ui/
index.ts # headerQuery
query.ts
index.ts
__factories__/
header.factory.ts
nav-item.factory.ts
__contracts__/
header-repository.contract.ts
```
## What it must NOT import
- Any other feature package (`@repo/auth`, `@repo/blog`, etc.)
- Any app package
- `@repo/core-api`, `@repo/core-cms`, `@repo/core-trpc`, `@repo/core-ui` directly; only `@repo/core-shared`
> Note: `@repo/core-trpc` and `@repo/core-ui` are optional packages scaffolded via `pnpm turbo gen core-package trpc` / `ui`. If not present, these constraints still apply to any future installation.
## Cross-links
- ADR-012 (`docs/decisions/adr-012-feature-conventions.md`) — factory-style use cases, per-use-case controllers, file-naming conventions
- ADR-013 (`docs/decisions/adr-013-input-output-unification.md`) — schemas-in-use-case, presenter, `./ui` subpath, error middleware

View File

@@ -0,0 +1,11 @@
# Changelog — @repo/navigation
All notable changes to the `navigation` feature package. Maintained by [release-please](https://github.com/googleapis/release-please) on merges to `main`. See [ADR-021](../../docs/decisions/adr-021-versioning-and-changelog.md) and [`docs/guides/releasing.md`](../../docs/guides/releasing.md).
## 0.1.0 (2026-05-13)
### Initial baseline
The `navigation` feature established at v0.1.0 alongside the hybrid versioning rollout (ADR-021). The feature has been stable since the template-reset cleanup; this is the first formally versioned baseline.
Future entries appear above this section as release-please assembles them from conventional commits scoped to `packages/navigation/**` since the last release.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,41 @@
{
"name": "@repo/navigation",
"private": true,
"version": "0.1.0",
"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",
"./di/bind-dev-seed": "./src/di/bind-dev-seed.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:^",
"@tanstack/react-query": "^5.66.0",
"@trpc/client": "^11.17.0",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"react": "^19.0.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",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,86 @@
import { it, expect, beforeEach, describe } from "vitest";
import { defineContractSuite } from "@repo/core-testing/contract";
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
import type { Header } from "../entities/models/header";
/**
* Known fixtures that every implementation's `buildSubject` must pre-seed.
* Exported so that test files can pass them to `MockHeaderRepository` or the
* Payload stub without duplicating definitions.
*/
export const CONTRACT_HEADER_SEED: Header = {
items: [
{ label: "Home", href: "/", external: false },
{ label: "Blog", href: "/blog", external: false },
{ label: "Docs", href: "/docs", external: true },
],
};
/**
* Contract for IHeaderRepository.
*
* Header is a singleton (Payload Global). The interface exposes only
* getHeader(). The contract verifies the shape, count, and order of items.
*/
export const headerRepositoryContract = defineContractSuite<IHeaderRepository>(
"IHeaderRepository",
({ buildSubject, getTracer }) => {
let repo: IHeaderRepository;
beforeEach(async () => {
repo = await buildSubject();
});
// --- getHeader ---
it("getHeader returns an object with an items array of the seeded length", async () => {
const header = await repo.getHeader();
expect(header).toBeDefined();
expect(header.items).toBeInstanceOf(Array);
expect(header.items).toHaveLength(3);
});
it("getHeader items appear in the seeded order with correct shape", async () => {
const header = await repo.getHeader();
expect(header.items[0]?.label).toBe("Home");
expect(header.items[0]?.href).toBe("/");
expect(header.items[0]?.external).toBe(false);
expect(header.items[1]?.label).toBe("Blog");
expect(header.items[1]?.href).toBe("/blog");
expect(header.items[1]?.external).toBe(false);
expect(header.items[2]?.label).toBe("Docs");
expect(header.items[2]?.href).toBe("/docs");
expect(header.items[2]?.external).toBe(true);
});
it("getHeader items have label, href, and external fields", async () => {
const header = await repo.getHeader();
for (const item of header.items) {
expect(typeof item.label).toBe("string");
expect(item.label.length).toBeGreaterThan(0);
expect(typeof item.href).toBe("string");
expect(item.href.length).toBeGreaterThan(0);
expect(typeof item.external).toBe("boolean");
}
});
it("getHeader logoId is string or undefined", async () => {
const header = await repo.getHeader();
expect(
header.logoId === undefined || typeof header.logoId === "string",
).toBe(true);
});
describe("span emission", () => {
it("getHeader emits header.getHeader span with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.getHeader();
const span = tracer.findSpan("header.getHeader");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
});
});
},
);

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from "vitest";
import { headerFactory } from "@/__factories__/header.factory";
describe("headerFactory", () => {
beforeEach(() => headerFactory.reset());
it("returns a Header with stable defaults", () => {
const h = headerFactory.build();
expect(h.items).toHaveLength(0);
expect(h.logoId).toBe("logo-1");
});
it("applies overrides", () => {
const h = headerFactory.build({
logoId: "logo-42",
items: [{ label: "Home", href: "/", external: false }],
});
expect(h.logoId).toBe("logo-42");
expect(h.items).toHaveLength(1);
expect(h.items[0]?.label).toBe("Home");
});
it("builds multiple headers independently", () => {
const a = headerFactory.build({ items: [{ label: "A", href: "/a", external: false }] });
const b = headerFactory.build({ items: [] });
expect(a.items).toHaveLength(1);
expect(b.items).toHaveLength(0);
});
it("logoId reflects sequence for deterministic buildList output", () => {
const first = headerFactory.build();
const second = headerFactory.build();
expect(first.logoId).toBe("logo-1");
expect(second.logoId).toBe("logo-2");
});
});

View File

@@ -0,0 +1,7 @@
import { defineFactory } from "@repo/core-testing/factory";
import type { Header } from "../entities/models/header";
export const headerFactory = defineFactory<Header>(({ sequence }) => ({
logoId: `logo-${sequence}`,
items: [],
}));

View File

@@ -0,0 +1,2 @@
export { headerFactory } from "./header.factory";
export { navItemFactory } from "./nav-item.factory";

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from "vitest";
import { navItemFactory } from "@/__factories__/nav-item.factory";
describe("navItemFactory", () => {
beforeEach(() => navItemFactory.reset());
it("returns a HeaderItem with stable defaults", () => {
const item = navItemFactory.build();
expect(item.label).toBe("Item 1");
expect(item.href).toBe("/item-1");
expect(item.external).toBe(false);
});
it("applies overrides", () => {
const item = navItemFactory.build({ label: "Home", href: "/", external: true });
expect(item.label).toBe("Home");
expect(item.href).toBe("/");
expect(item.external).toBe(true);
});
it("increments sequence per build", () => {
const a = navItemFactory.build();
const b = navItemFactory.build();
expect(a.label).toBe("Item 1");
expect(b.label).toBe("Item 2");
expect(a.href).toBe("/item-1");
expect(b.href).toBe("/item-2");
});
});

View File

@@ -0,0 +1,8 @@
import { defineFactory } from "@repo/core-testing/factory";
import type { HeaderItem } from "../entities/models/header";
export const navItemFactory = defineFactory<HeaderItem>(({ sequence }) => ({
label: `Item ${sequence}`,
href: `/item-${sequence}`,
external: false,
}));

View File

@@ -0,0 +1,30 @@
import { headerFactory } from "../__factories__/header.factory";
import { navItemFactory } from "../__factories__/nav-item.factory";
import type { Header } from "../entities/models/header";
/**
* Realistic navigation seed for dev mode + storybook stories.
*
* Built from `headerFactory` and `navItemFactory` so factory defaults take
* care of boring fields and we only override what makes the nav look like a
* real site header.
*
* Lazily produced so importing this module is side-effect-free — the
* factory's sequence counter only advances when a binder calls
* `buildDevHeader()`.
*/
export function buildDevHeader(): Header {
return headerFactory.build({
logoId: "logo-main",
items: [
navItemFactory.build({ label: "Home", href: "/", external: false }),
navItemFactory.build({ label: "Blog", href: "/blog", external: false }),
navItemFactory.build({ label: "About", href: "/about", external: false }),
navItemFactory.build({
label: "Pricing",
href: "/pricing",
external: false,
}),
],
});
}

View File

@@ -0,0 +1,5 @@
import type { Header } from "../../entities/models/header";
export interface IHeaderRepository {
getHeader(): Promise<Header>;
}

View File

@@ -0,0 +1,23 @@
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";
describe("getHeaderUseCase", () => {
it("returns the seeded header items", async () => {
const repo = new MockHeaderRepository();
const useCase = getHeaderUseCase(repo);
const result = await useCase({});
expect(result.items.length).toBeGreaterThan(0);
expect(result.items[0]?.label).toBe("Home");
});
it("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

@@ -0,0 +1,27 @@
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) =>
// 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

@@ -0,0 +1,76 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
import { RecordingEventBus, RecordingJobQueue } from "@repo/core-testing/instrumentation";
import { bindDevSeedNavigation } from "@/di/bind-dev-seed";
import { navigationContainer } from "@/di/container";
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
import type { IHeaderRepository } from "@/application/repositories/header.repository.interface";
const noop = { tracer: new NoopTracer(), logger: new NoopLogger() };
describe("bindDevSeedNavigation", () => {
// Each test starts from the default mock binding and tears down afterwards
// so the global navigationContainer state stays clean for siblings.
beforeEach(() => {
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
}
navigationContainer
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
.to(MockHeaderRepository);
});
afterEach(() => {
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
}
navigationContainer
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
.to(MockHeaderRepository);
});
it("populates the header repository with the dev header", async () => {
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
const repo = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository,
);
const header = await repo.getHeader();
expect(header).toBeDefined();
});
it("seeds a header with a non-empty items array", async () => {
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
const repo = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository,
);
const header = await repo.getHeader();
expect(header.items.length).toBeGreaterThan(0);
const homeItem = header.items.find((item) => item.label === "Home");
expect(homeItem).toBeDefined();
expect(homeItem?.href).toBe("/");
});
it("is idempotent — calling twice rebuilds a fresh populated repo", async () => {
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
const before = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository,
);
const beforeHeader = await before.getHeader();
await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() });
const after = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository,
);
const afterHeader = await after.getHeader();
expect(afterHeader.items.length).toBe(beforeHeader.items.length);
// It's a fresh instance — not the previous one.
expect(after).not.toBe(before);
});
});

View File

@@ -0,0 +1,107 @@
import {
withSpan,
withCapture,
INSTRUMENTATION_SYMBOLS,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindContext } from "@repo/core-shared/di";
import {
assertFeatureConformance,
wireUseCase,
} from "@repo/core-shared/conformance";
import { navigationManifest } from "../feature.manifest";
import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols";
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock";
import { buildDevHeader } from "../__seeds__/dev";
import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
/**
* Replace the default mock with a populated one for dev mode + storybook.
*
* Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with
* `bindProductionNavigation(config)`. Tests must NOT call this — they
* construct `new MockHeaderRepository()` directly and seed via factories
* per-test.
*
* Idempotent: safe to call multiple times; each call rebuilds a fresh
* populated repo and rebinds the symbol.
*/
export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
// Bind shared instrumentation into feature container
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
navigationContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
navigationContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
}
const repo = new MockHeaderRepository(buildDevHeader(), tracer, logger);
navigationContainer
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
.toConstantValue(repo);
// Use case
const wrappedGetHeader = wireUseCase({
container: navigationContainer,
symbol: NAVIGATION_SYMBOLS.IGetHeaderUseCase,
factory: getHeaderUseCase,
deps: [repo],
feature: "navigation",
layer: "use-case",
name: "getHeader",
tracer,
logger,
});
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IGetHeaderController)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IGetHeaderController);
}
navigationContainer
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
.toConstantValue(
withSpan(
tracer,
{ name: "navigation.getHeader", op: "controller" },
withCapture(
logger,
{
feature: "navigation",
layer: "controller",
name: "navigation.getHeader",
},
getHeaderController(wrappedGetHeader),
),
),
);
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;
void realtimeRegistry;
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check (dev-seed mode).
assertFeatureConformance(
navigationContainer,
navigationManifest,
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
ctx,
);
}

View File

@@ -0,0 +1,17 @@
import "reflect-metadata";
import { describe, expect, it } from "vitest";
import type { SanitizedConfig } from "payload";
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
import { bindProductionNavigation } from "@/di/bind-production";
describe("bindProductionNavigation — boot-time conformance", () => {
it("binds every manifest use case through withSpan + withCapture", () => {
expect(() =>
bindProductionNavigation({
config: {} as SanitizedConfig,
tracer: new NoopTracer(),
logger: new NoopLogger(),
}),
).not.toThrow();
});
});

View File

@@ -0,0 +1,97 @@
import {
withSpan,
withCapture,
INSTRUMENTATION_SYMBOLS,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext } from "@repo/core-shared/di";
import {
assertFeatureConformance,
wireUseCase,
} from "@repo/core-shared/conformance";
import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols";
import { navigationManifest } from "../feature.manifest";
import { HeaderRepository } from "../infrastructure/repositories/header.repository";
import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
export function bindProductionNavigation(ctx: BindProductionContext): void {
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
// Bind shared instrumentation into feature container
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (navigationContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
navigationContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
navigationContainer
.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER)
.toConstantValue(tracer);
navigationContainer
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger);
// Real repository
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
}
const repo = new HeaderRepository(config, tracer, logger);
navigationContainer
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
.toConstantValue(repo);
// Use case
const wrappedGetHeader = wireUseCase({
container: navigationContainer,
symbol: NAVIGATION_SYMBOLS.IGetHeaderUseCase,
factory: getHeaderUseCase,
deps: [repo],
feature: "navigation",
layer: "use-case",
name: "getHeader",
tracer,
logger,
});
// Controller — wrapped with span at bind time
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IGetHeaderController)) {
navigationContainer.unbind(NAVIGATION_SYMBOLS.IGetHeaderController);
}
navigationContainer
.bind(NAVIGATION_SYMBOLS.IGetHeaderController)
.toConstantValue(
withSpan(
tracer,
{ name: "navigation.getHeader", op: "controller" },
withCapture(
logger,
{
feature: "navigation",
layer: "controller",
name: "navigation.getHeader",
},
getHeaderController(wrappedGetHeader),
),
),
);
// bus + queue are passed through; generated handlers consume them at the anchors below.
void bus;
void queue;
void realtime;
void realtimeRegistry;
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check.
assertFeatureConformance(
navigationContainer,
navigationManifest,
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
ctx,
);
}

View File

@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols";
import { NavigationModule } from "./module";
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
import type { IHeaderRepository } from "@/application/repositories/header.repository.interface";
import type { IGetHeaderUseCase } from "@/application/use-cases/get-header.use-case";
import type { IGetHeaderController } from "@/interface-adapters/controllers/get-header.controller";
describe("navigationContainer", () => {
beforeEach(() => {
navigationContainer.unbindAll();
navigationContainer.load(NavigationModule);
});
afterEach(() => {
navigationContainer.unbindAll();
});
it("resolves IHeaderRepository to MockHeaderRepository", () => {
const repo = navigationContainer.get<IHeaderRepository>(
NAVIGATION_SYMBOLS.IHeaderRepository,
);
expect(repo).toBeInstanceOf(MockHeaderRepository);
});
it("resolves IGetHeaderUseCase as a function", () => {
const useCase = navigationContainer.get<IGetHeaderUseCase>(
NAVIGATION_SYMBOLS.IGetHeaderUseCase,
);
expect(typeof useCase).toBe("function");
});
it("resolves IGetHeaderController as a function", () => {
const controller = navigationContainer.get<IGetHeaderController>(
NAVIGATION_SYMBOLS.IGetHeaderController,
);
expect(typeof controller).toBe("function");
});
});

View File

@@ -0,0 +1,6 @@
import "reflect-metadata";
import { Container } from "inversify";
import { NavigationModule } from "./module";
export const navigationContainer = new Container({ defaultScope: "Singleton" });
navigationContainer.load(NavigationModule);

View File

@@ -0,0 +1,33 @@
import { ContainerModule, type interfaces } from "inversify";
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock";
import {
getHeaderUseCase,
type IGetHeaderUseCase,
} from "../application/use-cases/get-header.use-case";
import {
getHeaderController,
type IGetHeaderController,
} from "../interface-adapters/controllers/get-header.controller";
import { NAVIGATION_SYMBOLS } from "./symbols";
export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => {
bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository).to(
MockHeaderRepository,
);
bind<IGetHeaderUseCase>(NAVIGATION_SYMBOLS.IGetHeaderUseCase).toDynamicValue(
(ctx) =>
getHeaderUseCase(
ctx.container.get<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository),
),
);
bind<IGetHeaderController>(NAVIGATION_SYMBOLS.IGetHeaderController).toDynamicValue(
(ctx) =>
getHeaderController(
ctx.container.get<IGetHeaderUseCase>(NAVIGATION_SYMBOLS.IGetHeaderUseCase),
),
);
});

View File

@@ -0,0 +1,10 @@
export const NAVIGATION_SYMBOLS = {
IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
// Use cases
IGetHeaderUseCase: Symbol.for("navigation:IGetHeaderUseCase"),
// Controllers
IGetHeaderController: Symbol.for("navigation:IGetHeaderController"),
// <gen:event-handler-symbols>
// <gen:job-symbols>
// <gen:realtime-handler-symbols>
} as const;

View File

@@ -0,0 +1,6 @@
export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "InputParseError";
}
}

View File

@@ -0,0 +1,6 @@
export class HeaderNotFoundError extends Error {
constructor(message = "Header not found", options?: ErrorOptions) {
super(message, options);
this.name = "HeaderNotFoundError";
}
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { headerSchema, headerItemSchema } from "./header";
describe("headerItemSchema", () => {
it("accepts a valid nav item", () => {
const result = headerItemSchema.parse({
label: "Home",
href: "/",
});
expect(result.label).toBe("Home");
expect(result.external).toBe(false);
});
it("accepts an external nav item", () => {
const result = headerItemSchema.parse({
label: "Blog",
href: "https://blog.example.com",
external: true,
});
expect(result.external).toBe(true);
});
it("rejects an empty label", () => {
expect(() => headerItemSchema.parse({ label: "", href: "/" })).toThrow();
});
it("rejects a label over 64 chars", () => {
expect(() =>
headerItemSchema.parse({ label: "x".repeat(65), href: "/" }),
).toThrow();
});
it("rejects an empty href", () => {
expect(() =>
headerItemSchema.parse({ label: "Home", href: "" }),
).toThrow();
});
});
describe("headerSchema", () => {
it("accepts a valid header with items", () => {
const result = headerSchema.parse({
items: [{ label: "Home", href: "/" }],
});
expect(result.items).toHaveLength(1);
expect(result.logoId).toBeUndefined();
});
it("accepts a header with a logoId", () => {
const result = headerSchema.parse({
logoId: "logo-abc",
items: [],
});
expect(result.logoId).toBe("logo-abc");
});
it("accepts a header with no items", () => {
const result = headerSchema.parse({ items: [] });
expect(result.items).toHaveLength(0);
});
});

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
export const headerItemSchema = z.object({
label: z.string().min(1).max(64),
href: z.string().min(1),
external: z.boolean().default(false),
});
export const headerSchema = z.object({
logoId: z.string().optional(),
items: z.array(headerItemSchema),
});
export type Header = z.infer<typeof headerSchema>;
export type HeaderItem = z.infer<typeof headerItemSchema>;

View File

@@ -0,0 +1,35 @@
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The navigation feature's conformance manifest.
*/
export const navigationManifest = defineFeature({
name: "navigation",
requiredCores: [],
useCases: {
getHeader: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
coverage: {
bands: {
baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
"use-cases": {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
controllers: {
statements: 100,
branches: 95,
functions: 100,
lines: 100,
},
},
mutationTargets: ["entities", "use-cases"],
},
} as const);
export type NavigationManifest = typeof navigationManifest;

View File

@@ -0,0 +1,23 @@
export type { Header, HeaderItem } from "./entities/models/header";
export type { NavigationRouter } from "./integrations/api/router";
export { HeaderNotFoundError } from "./entities/errors/header";
export { InputParseError } from "./entities/errors/common";
// Use case schemas + types
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";
// <gen:events>
// <gen:realtime-channels>
export {
navigationManifest,
type NavigationManifest,
} from "./feature.manifest";

View File

@@ -0,0 +1,12 @@
import { describe } from "vitest";
import { RecordingTracer } from "@repo/core-testing/instrumentation";
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
import { headerRepositoryContract, CONTRACT_HEADER_SEED } from "@/__contracts__/header-repository.contract";
describe("MockHeaderRepository", () => {
const tracer = new RecordingTracer();
headerRepositoryContract.run(
() => new MockHeaderRepository(CONTRACT_HEADER_SEED, tracer),
{ tracer: () => tracer },
);
});

View File

@@ -0,0 +1,45 @@
import "reflect-metadata";
import { injectable } from "inversify";
import {
NoopTracer,
NoopLogger,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IHeaderRepository } from "../../application/repositories/header.repository.interface";
import type { Header, HeaderItem } from "../../entities/models/header";
const DEFAULT_ITEMS: HeaderItem[] = [
{ label: "Home", href: "/", external: false },
{ label: "Blog", href: "/blog", external: false },
{ label: "Docs", href: "/docs", external: true },
];
@injectable()
export class MockHeaderRepository implements IHeaderRepository {
private readonly data: Header;
private tracer: ITracer;
private logger: ILogger;
constructor(
initialData?: Header,
tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(),
) {
this.data = initialData ?? { items: DEFAULT_ITEMS };
this.tracer = tracer;
this.logger = logger;
void this.logger; // currently unused; reserved for future mock-thrown captures
}
async getHeader(): Promise<Header> {
return this.tracer.startSpan(
{ name: "header.getHeader", op: "repository", attributes: {} },
async (span) => {
span.setAttribute("itemCount", this.data.items.length);
return this.data;
},
);
}
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import {
RecordingTracer,
RecordingLogger,
} from "@repo/core-testing/instrumentation";
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
// Mock repo also wraps in spans.
describe("MockHeaderRepository emits spans", () => {
it("getHeader emits one span with op='repository'", async () => {
const tracer = new RecordingTracer();
const logger = new RecordingLogger();
const repo = new MockHeaderRepository(undefined, tracer, logger);
await repo.getHeader();
expect(tracer.spans).toHaveLength(1);
expect(tracer.spans[0]).toMatchObject({
name: "header.getHeader",
op: "repository",
});
expect(typeof tracer.spans[0]!.attributes.itemCount).toBe("number");
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi } from "vitest";
import { RecordingTracer } from "@repo/core-testing/instrumentation";
import { HeaderRepository } from "@/infrastructure/repositories/header.repository";
import { headerRepositoryContract, CONTRACT_HEADER_SEED } from "@/__contracts__/header-repository.contract";
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
// ---------------------------------------------------------------------------
// In-memory Payload stub for header (Global)
// ---------------------------------------------------------------------------
function buildHeaderStub(overrides?: Record<string, unknown>) {
return {
findGlobal: vi.fn(async () => {
return { ...CONTRACT_HEADER_SEED, ...overrides };
}),
};
}
vi.mock("payload", () => ({
getPayload: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Contract suite
// ---------------------------------------------------------------------------
describe("HeaderRepository", () => {
describe("contract", () => {
const tracer = new RecordingTracer();
headerRepositoryContract.run(
async () => {
const stub = buildHeaderStub();
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
return new HeaderRepository(stubPayloadConfig, tracer);
},
{ tracer: () => tracer },
);
});
// ---------------------------------------------------------------------------
// Logo field branch coverage
// ---------------------------------------------------------------------------
it("resolves logoId from a relation object ({ id })", async () => {
const stub = buildHeaderStub({ logo: { id: "abc-123" } });
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new HeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.logoId).toBe("abc-123");
});
it("resolves logoId from a scalar id (string)", async () => {
const stub = buildHeaderStub({ logo: "scalar-id" });
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new HeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.logoId).toBe("scalar-id");
});
it("resolves logoId as undefined when logo is null", async () => {
const stub = buildHeaderStub({ logo: null });
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new HeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.logoId).toBeUndefined();
});
it("maps null item fields to empty strings and false", async () => {
const stub = {
findGlobal: vi.fn(async () => ({
items: [{ label: null, href: null, external: null }],
})),
};
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new HeaderRepository(stubPayloadConfig);
const header = await repo.getHeader();
expect(header.items[0]?.label).toBe("");
expect(header.items[0]?.href).toBe("");
expect(header.items[0]?.external).toBe(false);
});
});

View File

@@ -0,0 +1,79 @@
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import {
NoopTracer,
NoopLogger,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IHeaderRepository } from "../../application/repositories/header.repository.interface";
import type { Header, HeaderItem } from "../../entities/models/header";
type PayloadHeaderGlobal = {
logo?: string | number | { id: string | number } | null;
items?: Array<{
label?: string | null;
href?: string | null;
external?: boolean | null;
}> | null;
};
const FEATURE = "navigation" as const;
const REPO = "header" as const;
@injectable()
export class HeaderRepository implements IHeaderRepository {
private config: SanitizedConfig;
private tracer: ITracer;
private logger: ILogger;
constructor(
config: SanitizedConfig,
tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(),
) {
this.config = config;
this.tracer = tracer;
this.logger = logger;
}
async getHeader(): Promise<Header> {
return this.tracer.startSpan(
{ name: "header.getHeader", op: "repository", attributes: {} },
async (span) => {
try {
const payload = await getPayload({ config: this.config });
const doc = (await payload.findGlobal({
slug: "header",
overrideAccess: true,
})) as PayloadHeaderGlobal;
const logoId =
typeof doc.logo === "object" && doc.logo !== null
? String(doc.logo.id)
: doc.logo != null
? String(doc.logo)
: undefined;
const items: HeaderItem[] = (doc.items ?? []).map((item) => ({
label: item.label ?? "",
href: item.href ?? "",
external: item.external ?? false,
}));
span.setAttribute("itemCount", items.length);
return { logoId, items };
} catch (err) {
this.logger.captureException(err, {
tags: { feature: FEATURE, repo: REPO, method: "getHeader" },
});
span.setStatus("error", err instanceof Error ? err.message : String(err));
throw err;
}
},
);
}
}

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

@@ -0,0 +1,93 @@
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");
});
it("header returns 3 items", async () => {
const caller = navigationRouter.createCaller({});
const result = await caller.header({});
expect(result.items).toHaveLength(3);
});
});
describe("navigationRouter 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

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

View File

@@ -0,0 +1,34 @@
import type { GlobalConfig } from "payload";
export const header: GlobalConfig = {
slug: "header",
custom: {
retention: {
purgeSchedule: "monthly",
postDeletion: {
duration: "P90D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
admin: {
group: "Navigation",
},
fields: [
{
name: "logo",
type: "upload",
relationTo: "media",
},
{
name: "items",
type: "array",
fields: [
{ name: "label", type: "text", required: true },
{ name: "href", type: "text", required: true },
{ name: "external", type: "checkbox", defaultValue: false },
],
},
],
};

View File

@@ -0,0 +1,2 @@
export { header } from "./globals/header";
// <gen:job-tasks>

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { getHeaderController } from "@/interface-adapters/controllers/get-header.controller";
import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case";
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
import { InputParseError } from "@/entities/errors/common";
describe("getHeaderController", () => {
it("returns the header with items", async () => {
const repo = new MockHeaderRepository();
const useCase = getHeaderUseCase(repo);
const controller = getHeaderController(useCase);
const result = await controller({});
expect(result.items.length).toBeGreaterThan(0);
expect(result.items[0]?.label).toBe("Home");
});
it("throws InputParseError on unknown fields (strict schema)", async () => {
const repo = new MockHeaderRepository();
const useCase = getHeaderUseCase(repo);
const controller = getHeaderController(useCase);
await expect(controller({ unknownField: 1 })).rejects.toThrow(
InputParseError,
);
});
it("InputParseError preserves the cause from Zod", async () => {
const repo = new MockHeaderRepository();
const useCase = getHeaderUseCase(repo);
const controller = getHeaderController(useCase);
try {
await controller({ unknownField: 1 });
expect.fail("expected InputParseError");
} catch (err) {
expect(err).toBeInstanceOf(InputParseError);
expect((err as Error).name).toBe("InputParseError");
expect((err as InputParseError).cause).toBeDefined();
}
});
});

View File

@@ -0,0 +1,23 @@
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 (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,46 @@
"use client";
import { useHeader } from "../hooks/use-header";
export type SiteHeaderProps = {
siteName: string;
siteDescription?: string;
};
export function SiteHeader({ siteName, siteDescription }: SiteHeaderProps) {
const { data: header } = useHeader();
return (
<header className="border-b border-border bg-background px-6 py-4">
<div className="mx-auto flex max-w-5xl items-center justify-between">
<div>
<a href="/">
<span className="text-lg font-semibold text-foreground">
{siteName}
</span>
</a>
{siteDescription ? (
<p className="text-sm text-muted-foreground">{siteDescription}</p>
) : null}
</div>
<nav>
<ul className="flex gap-4">
{header.items.map((item) => (
<li key={item.href}>
<a
href={item.href}
{...(item.external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className="text-sm font-medium text-foreground hover:text-primary"
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,27 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@repo/core-trpc";
import { navigationContainer } from "../../di/container";
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller";
import { SiteHeader as SiteHeaderClient } from "./site-header.client";
export async function SiteHeader({
siteName,
siteDescription,
}: {
siteName: string;
siteDescription?: string;
}) {
const controller = navigationContainer.get<IGetHeaderController>(
NAVIGATION_SYMBOLS.IGetHeaderController,
);
const header = await controller({});
const queryClient = getQueryClient();
queryClient.setQueryData(["navigation", "header", { input: {} }], header);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<SiteHeaderClient siteName={siteName} siteDescription={siteDescription} />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function useHeader() {
const trpc = useTRPC();
return useSuspenseQuery(trpc.navigation.header.queryOptions({})) as {
data: {
items: { label: string; href: string; external: boolean }[];
logoId?: string;
};
};
}

View File

@@ -0,0 +1,3 @@
export { headerQuery } from "./query";
export { useHeader } from "./hooks/use-header";
export { SiteHeader } from "./components/site-header.server";

View File

@@ -0,0 +1,9 @@
type TrpcClient = {
navigation: {
header: { queryOptions: () => unknown };
};
};
export function headerQuery(client: TrpcClient) {
return client.navigation.header.queryOptions();
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"lib": ["ES2022", "DOM"],
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["feature"]
}

View File

@@ -0,0 +1,32 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
import {
DEFAULT_COVERAGE_BANDS,
vitestThresholdsFromBands,
} from "@repo/core-shared/conformance/coverage";
// Coverage thresholds derived from DEFAULT_COVERAGE_BANDS via the shared
// helper (ADR-020). The feature.manifest.ts `coverage.bands` section
// declares these for boot-time `assertFeatureConformance`. Edit the
// manifest when adjusting per-feature bands.
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
exclude: [
// DI bootstrap — wires InversifyJS at app startup; not unit-testable
"src/di/bind-production.ts",
// Pure TypeScript interface files — not executable
"src/application/repositories/**",
// Payload CMS global config — declarative data, tested via Payload integration
"src/integrations/cms/**",
// React Query option builders — integration-tested in apps
"src/ui/**",
],
thresholds: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS),
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});