diff --git a/packages/navigation/src/__contracts__/header-repository.contract.ts b/packages/navigation/src/__contracts__/header-repository.contract.ts index 4d4f335..0badc75 100644 --- a/packages/navigation/src/__contracts__/header-repository.contract.ts +++ b/packages/navigation/src/__contracts__/header-repository.contract.ts @@ -35,13 +35,13 @@ export const headerRepositoryContract = defineContractSuite( 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); + expect(header).not.toBeNull(); + 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(); + 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); @@ -54,7 +54,7 @@ export const headerRepositoryContract = defineContractSuite( }); it("getHeader items have label, href, and external fields", async () => { - const header = await repo.getHeader(); + const header = (await repo.getHeader())!; for (const item of header.items) { expect(typeof item.label).toBe("string"); expect(item.label.length).toBeGreaterThan(0); @@ -65,7 +65,7 @@ export const headerRepositoryContract = defineContractSuite( }); it("getHeader logoId is string or undefined", async () => { - const header = await repo.getHeader(); + const header = (await repo.getHeader())!; expect( header.logoId === undefined || typeof header.logoId === "string", ).toBe(true); diff --git a/packages/navigation/src/application/repositories/header.repository.interface.ts b/packages/navigation/src/application/repositories/header.repository.interface.ts index c0aeadf..d64626e 100644 --- a/packages/navigation/src/application/repositories/header.repository.interface.ts +++ b/packages/navigation/src/application/repositories/header.repository.interface.ts @@ -1,5 +1,6 @@ import type { Header } from "../../entities/models/header"; export interface IHeaderRepository { - getHeader(): Promise
; + /** Returns the header, or `null` when no header is configured. */ + getHeader(): Promise
; } diff --git a/packages/navigation/src/application/use-cases/get-header.use-case.test.ts b/packages/navigation/src/application/use-cases/get-header.use-case.test.ts index 995754c..39d71c0 100644 --- a/packages/navigation/src/application/use-cases/get-header.use-case.test.ts +++ b/packages/navigation/src/application/use-cases/get-header.use-case.test.ts @@ -2,6 +2,7 @@ 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"; +import { HeaderNotFoundError } from "@/entities/errors/header"; describe("getHeaderUseCase", () => { it("returns the seeded header items", async () => { @@ -12,6 +13,12 @@ describe("getHeaderUseCase", () => { expect(result.items[0]?.label).toBe("Home"); }); + it("throws HeaderNotFoundError when no header is configured", async () => { + const repo = new MockHeaderRepository(null); + const useCase = getHeaderUseCase(repo); + await expect(useCase({})).rejects.toBeInstanceOf(HeaderNotFoundError); + }); + it("throws ZodError when repository returns malformed header", async () => { const malformedRepo = { getHeader: async () => diff --git a/packages/navigation/src/di/bind-dev-seed.test.ts b/packages/navigation/src/di/bind-dev-seed.test.ts index 2c6bcc7..202ce8a 100644 --- a/packages/navigation/src/di/bind-dev-seed.test.ts +++ b/packages/navigation/src/di/bind-dev-seed.test.ts @@ -1,7 +1,10 @@ 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 { + 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"; @@ -32,7 +35,11 @@ describe("bindDevSeedNavigation", () => { }); it("populates the header repository with the dev header", async () => { - await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() }); + await bindDevSeedNavigation({ + ...noop, + bus: new RecordingEventBus(), + queue: new RecordingJobQueue(), + }); const repo = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, @@ -43,31 +50,44 @@ describe("bindDevSeedNavigation", () => { }); it("seeds a header with a non-empty items array", async () => { - await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() }); + await bindDevSeedNavigation({ + ...noop, + bus: new RecordingEventBus(), + queue: new RecordingJobQueue(), + }); const repo = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, ); const header = await repo.getHeader(); - expect(header.items.length).toBeGreaterThan(0); - const homeItem = header.items.find((item) => item.label === "Home"); + expect(header).not.toBeNull(); + 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() }); + await bindDevSeedNavigation({ + ...noop, + bus: new RecordingEventBus(), + queue: new RecordingJobQueue(), + }); const before = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, ); - const beforeHeader = await before.getHeader(); + const beforeHeader = (await before.getHeader())!; - await bindDevSeedNavigation({ ...noop, bus: new RecordingEventBus(), queue: new RecordingJobQueue() }); + await bindDevSeedNavigation({ + ...noop, + bus: new RecordingEventBus(), + queue: new RecordingJobQueue(), + }); const after = navigationContainer.get( NAVIGATION_SYMBOLS.IHeaderRepository, ); - const afterHeader = await after.getHeader(); + const afterHeader = (await after.getHeader())!; expect(afterHeader.items.length).toBe(beforeHeader.items.length); // It's a fresh instance — not the previous one. diff --git a/packages/navigation/src/infrastructure/repositories/header.repository.mock.ts b/packages/navigation/src/infrastructure/repositories/header.repository.mock.ts index b112dcf..d4c1a15 100644 --- a/packages/navigation/src/infrastructure/repositories/header.repository.mock.ts +++ b/packages/navigation/src/infrastructure/repositories/header.repository.mock.ts @@ -18,26 +18,31 @@ const DEFAULT_ITEMS: HeaderItem[] = [ @injectable() export class MockHeaderRepository implements IHeaderRepository { - private readonly data: Header; + private readonly data: Header | null; private tracer: ITracer; private logger: ILogger; + /** + * Pass `null` as `initialData` to simulate an unconfigured header + * (getHeader resolves to `null`); omit it for the default seed. + */ constructor( - initialData?: Header, + initialData?: Header | null, tracer: ITracer = new NoopTracer(), logger: ILogger = new NoopLogger(), ) { - this.data = initialData ?? { items: DEFAULT_ITEMS }; + this.data = + initialData === undefined ? { items: DEFAULT_ITEMS } : initialData; this.tracer = tracer; this.logger = logger; void this.logger; // currently unused; reserved for future mock-thrown captures } - async getHeader(): Promise
{ + async getHeader(): Promise
{ return this.tracer.startSpan( { name: "header.getHeader", op: "repository", attributes: {} }, async (span) => { - span.setAttribute("itemCount", this.data.items.length); + span.setAttribute("itemCount", this.data?.items.length ?? 0); return this.data; }, ); diff --git a/packages/navigation/src/infrastructure/repositories/header.repository.test.ts b/packages/navigation/src/infrastructure/repositories/header.repository.test.ts index fedc96b..6f47a02 100644 --- a/packages/navigation/src/infrastructure/repositories/header.repository.test.ts +++ b/packages/navigation/src/infrastructure/repositories/header.repository.test.ts @@ -1,8 +1,12 @@ 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 { + headerRepositoryContract, + CONTRACT_HEADER_SEED, +} from "@/__contracts__/header-repository.contract"; import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; +import { headerSchema } from "@/entities/models/header"; // --------------------------------------------------------------------------- // In-memory Payload stub for header (Global) @@ -75,10 +79,15 @@ describe("HeaderRepository", () => { expect(header.logoId).toBeUndefined(); }); - it("maps null item fields to empty strings and false", async () => { + it("skips items with missing label or href instead of emitting invalid ones", async () => { const stub = { findGlobal: vi.fn(async () => ({ - items: [{ label: null, href: null, external: null }], + items: [ + { label: null, href: null, external: null }, + { label: "Valid", href: "/valid", external: null }, + { label: "", href: "/empty-label", external: false }, + { label: "No href", href: "", external: false }, + ], })), }; const { getPayload } = await import("payload"); @@ -87,8 +96,26 @@ describe("HeaderRepository", () => { 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); + expect(header.items).toEqual([ + { label: "Valid", href: "/valid", external: false }, + ]); + }); + + it("skipped items keep the result valid against headerSchema", async () => { + const stub = { + findGlobal: vi.fn(async () => ({ + items: [ + { label: "", href: "", external: false }, + { label: "Home", href: "/", external: false }, + ], + })), + }; + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + + const repo = new HeaderRepository(stubPayloadConfig); + const header = await repo.getHeader(); + + expect(() => headerSchema.parse(header)).not.toThrow(); }); }); diff --git a/packages/navigation/src/infrastructure/repositories/header.repository.ts b/packages/navigation/src/infrastructure/repositories/header.repository.ts index 7063257..2750d31 100644 --- a/packages/navigation/src/infrastructure/repositories/header.repository.ts +++ b/packages/navigation/src/infrastructure/repositories/header.repository.ts @@ -58,11 +58,20 @@ export class HeaderRepository implements IHeaderRepository { ? String(doc.logo) : undefined; - const items: HeaderItem[] = (doc.items ?? []).map((item) => ({ - label: item.label ?? "", - href: item.href ?? "", - external: item.external ?? false, - })); + // headerItemSchema requires non-empty label + href — skip + // incomplete CMS rows instead of emitting invalid items that + // would fail output validation downstream. + const items: HeaderItem[] = (doc.items ?? []).flatMap((item) => + item.label && item.href + ? [ + { + label: item.label, + href: item.href, + external: item.external ?? false, + }, + ] + : [], + ); span.setAttribute("itemCount", items.length); return { logoId, items }; @@ -70,7 +79,10 @@ export class HeaderRepository implements IHeaderRepository { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "getHeader" }, }); - span.setStatus("error", err instanceof Error ? err.message : String(err)); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); throw err; } }, diff --git a/packages/navigation/src/integrations/api/router.test.ts b/packages/navigation/src/integrations/api/router.test.ts index 00d9093..9b14a14 100644 --- a/packages/navigation/src/integrations/api/router.test.ts +++ b/packages/navigation/src/integrations/api/router.test.ts @@ -58,7 +58,7 @@ describe("navigationRouter error mapping", () => { @injectable() class NullHeaderRepository { async getHeader() { - return null as never; + return null; } }