Initial commit
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
36
packages/navigation/src/__factories__/header.factory.test.ts
Normal file
36
packages/navigation/src/__factories__/header.factory.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
7
packages/navigation/src/__factories__/header.factory.ts
Normal file
7
packages/navigation/src/__factories__/header.factory.ts
Normal 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: [],
|
||||
}));
|
||||
2
packages/navigation/src/__factories__/index.ts
Normal file
2
packages/navigation/src/__factories__/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { headerFactory } from "./header.factory";
|
||||
export { navItemFactory } from "./nav-item.factory";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
30
packages/navigation/src/__seeds__/dev.ts
Normal file
30
packages/navigation/src/__seeds__/dev.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Header } from "../../entities/models/header";
|
||||
|
||||
export interface IHeaderRepository {
|
||||
getHeader(): Promise<Header>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
76
packages/navigation/src/di/bind-dev-seed.test.ts
Normal file
76
packages/navigation/src/di/bind-dev-seed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
107
packages/navigation/src/di/bind-dev-seed.ts
Normal file
107
packages/navigation/src/di/bind-dev-seed.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
17
packages/navigation/src/di/bind-production.smoke.test.ts
Normal file
17
packages/navigation/src/di/bind-production.smoke.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
97
packages/navigation/src/di/bind-production.ts
Normal file
97
packages/navigation/src/di/bind-production.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
40
packages/navigation/src/di/container.test.ts
Normal file
40
packages/navigation/src/di/container.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
6
packages/navigation/src/di/container.ts
Normal file
6
packages/navigation/src/di/container.ts
Normal 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);
|
||||
33
packages/navigation/src/di/module.ts
Normal file
33
packages/navigation/src/di/module.ts
Normal 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),
|
||||
),
|
||||
);
|
||||
});
|
||||
10
packages/navigation/src/di/symbols.ts
Normal file
10
packages/navigation/src/di/symbols.ts
Normal 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;
|
||||
6
packages/navigation/src/entities/errors/common.ts
Normal file
6
packages/navigation/src/entities/errors/common.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InputParseError";
|
||||
}
|
||||
}
|
||||
6
packages/navigation/src/entities/errors/header.ts
Normal file
6
packages/navigation/src/entities/errors/header.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class HeaderNotFoundError extends Error {
|
||||
constructor(message = "Header not found", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "HeaderNotFoundError";
|
||||
}
|
||||
}
|
||||
61
packages/navigation/src/entities/models/header.test.ts
Normal file
61
packages/navigation/src/entities/models/header.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
packages/navigation/src/entities/models/header.ts
Normal file
15
packages/navigation/src/entities/models/header.ts
Normal 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>;
|
||||
35
packages/navigation/src/feature.manifest.ts
Normal file
35
packages/navigation/src/feature.manifest.ts
Normal 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;
|
||||
23
packages/navigation/src/index.ts
Normal file
23
packages/navigation/src/index.ts
Normal 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";
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/navigation/src/integrations/api/procedures.ts
Normal file
12
packages/navigation/src/integrations/api/procedures.ts
Normal 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"],
|
||||
]),
|
||||
);
|
||||
93
packages/navigation/src/integrations/api/router.test.ts
Normal file
93
packages/navigation/src/integrations/api/router.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
22
packages/navigation/src/integrations/api/router.ts
Normal file
22
packages/navigation/src/integrations/api/router.ts
Normal 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;
|
||||
34
packages/navigation/src/integrations/cms/globals/header.ts
Normal file
34
packages/navigation/src/integrations/cms/globals/header.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
2
packages/navigation/src/integrations/cms/index.ts
Normal file
2
packages/navigation/src/integrations/cms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { header } from "./globals/header";
|
||||
// <gen:job-tasks>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
46
packages/navigation/src/ui/components/site-header.client.tsx
Normal file
46
packages/navigation/src/ui/components/site-header.client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
packages/navigation/src/ui/components/site-header.server.tsx
Normal file
27
packages/navigation/src/ui/components/site-header.server.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
packages/navigation/src/ui/hooks/use-header.ts
Normal file
15
packages/navigation/src/ui/hooks/use-header.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
3
packages/navigation/src/ui/index.ts
Normal file
3
packages/navigation/src/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { headerQuery } from "./query";
|
||||
export { useHeader } from "./hooks/use-header";
|
||||
export { SiteHeader } from "./components/site-header.server";
|
||||
9
packages/navigation/src/ui/query.ts
Normal file
9
packages/navigation/src/ui/query.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type TrpcClient = {
|
||||
navigation: {
|
||||
header: { queryOptions: () => unknown };
|
||||
};
|
||||
};
|
||||
|
||||
export function headerQuery(client: TrpcClient) {
|
||||
return client.navigation.header.queryOptions();
|
||||
}
|
||||
Reference in New Issue
Block a user