From 10479c4d55cc34a14fb26ed26a5f9bd98e9f58d8 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 19:04:33 +0200 Subject: [PATCH] feat(features): add bind-dev-seed binders for auth/marketing-pages/navigation/media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the canonical blog pattern landed earlier on this branch. Per feature: - src/__seeds__/dev.ts — lazy buildDev() function using the feature's existing factory for sensible defaults - src/di/bind-dev-seed.ts — bindDevSeed() async function that rebinds the repo symbol(s) to a populated MockXRepository via .toConstantValue - src/di/bind-dev-seed.test.ts — 3+ tests per feature (populates, reachable by id/slug, idempotent) - package.json — adds ./di/bind-dev-seed subpath export Tests + use cases continue to construct mocks directly; the seed never runs from a *.test.ts path. App boot wiring (USE_DEV_SEED env branch) follows in a separate commit. Co-Authored-By: Claude Sonnet 4.6 --- packages/auth/package.json | 3 +- packages/auth/src/__seeds__/dev.ts | 27 +++++ packages/auth/src/di/bind-dev-seed.test.ts | 73 ++++++++++++++ packages/auth/src/di/bind-dev-seed.ts | 33 +++++++ packages/marketing-pages/package.json | 3 +- packages/marketing-pages/src/__seeds__/dev.ts | 55 +++++++++++ .../src/di/bind-dev-seed.test.ts | 98 +++++++++++++++++++ .../marketing-pages/src/di/bind-dev-seed.ts | 46 +++++++++ packages/media/package.json | 3 +- packages/media/src/__seeds__/dev.ts | 38 +++++++ packages/media/src/di/bind-dev-seed.test.ts | 71 ++++++++++++++ packages/media/src/di/bind-dev-seed.ts | 30 ++++++ packages/navigation/package.json | 3 +- packages/navigation/src/__seeds__/dev.ts | 26 +++++ .../navigation/src/di/bind-dev-seed.test.ts | 72 ++++++++++++++ packages/navigation/src/di/bind-dev-seed.ts | 26 +++++ 16 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/__seeds__/dev.ts create mode 100644 packages/auth/src/di/bind-dev-seed.test.ts create mode 100644 packages/auth/src/di/bind-dev-seed.ts create mode 100644 packages/marketing-pages/src/__seeds__/dev.ts create mode 100644 packages/marketing-pages/src/di/bind-dev-seed.test.ts create mode 100644 packages/marketing-pages/src/di/bind-dev-seed.ts create mode 100644 packages/media/src/__seeds__/dev.ts create mode 100644 packages/media/src/di/bind-dev-seed.test.ts create mode 100644 packages/media/src/di/bind-dev-seed.ts create mode 100644 packages/navigation/src/__seeds__/dev.ts create mode 100644 packages/navigation/src/di/bind-dev-seed.test.ts create mode 100644 packages/navigation/src/di/bind-dev-seed.ts diff --git a/packages/auth/package.json b/packages/auth/package.json index 682a45a..818159e 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -8,7 +8,8 @@ "./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-production": "./src/di/bind-production.ts", + "./di/bind-dev-seed": "./src/di/bind-dev-seed.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/auth/src/__seeds__/dev.ts b/packages/auth/src/__seeds__/dev.ts new file mode 100644 index 0000000..4eefec5 --- /dev/null +++ b/packages/auth/src/__seeds__/dev.ts @@ -0,0 +1,27 @@ +import { userFactory } from "../__factories__/user.factory.js"; +import type { User } from "../entities/models/user.js"; + +/** + * Realistic auth seed for dev mode + storybook stories. + * + * Built from `userFactory` so factory defaults take care of boring fields + * and we only override what makes the data look like a real user database. + * + * Lazily produced so importing this module is side-effect-free — the + * factory's sequence counter only advances when a binder calls + * `buildDevUsers()`. + */ +export function buildDevUsers(): User[] { + return [ + userFactory.build({ + id: "alice", + username: "alice", + passwordHash: "hashed_secret_alice", + }), + userFactory.build({ + id: "bob", + username: "bob", + passwordHash: "hashed_secret_bob", + }), + ]; +} diff --git a/packages/auth/src/di/bind-dev-seed.test.ts b/packages/auth/src/di/bind-dev-seed.test.ts new file mode 100644 index 0000000..a9d0ae2 --- /dev/null +++ b/packages/auth/src/di/bind-dev-seed.test.ts @@ -0,0 +1,73 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { bindDevSeedAuth } from "@/di/bind-dev-seed"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; +import type { IUsersRepository } from "@/application/repositories/users.repository.interface"; + +describe("bindDevSeedAuth", () => { + // Each test starts from the default empty-mock binding and tears down + // afterwards so the global authContainer state stays clean for siblings. + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .to(MockUsersRepository); + }); + + afterEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .to(MockUsersRepository); + }); + + it("populates the repository with the dev users", async () => { + await bindDevSeedAuth(); + + const repo = authContainer.get( + AUTH_SYMBOLS.IUsersRepository, + ); + const alice = await repo.getUserByUsername("alice"); + const bob = await repo.getUserByUsername("bob"); + + expect(alice).toBeDefined(); + expect(bob).toBeDefined(); + }); + + it("seeds alice reachable by username", async () => { + await bindDevSeedAuth(); + + const repo = authContainer.get( + AUTH_SYMBOLS.IUsersRepository, + ); + const alice = await repo.getUserByUsername("alice"); + + expect(alice).toBeDefined(); + expect(alice?.id).toBe("alice"); + expect(alice?.passwordHash).toBe("hashed_secret_alice"); + }); + + it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { + await bindDevSeedAuth(); + const before = authContainer.get( + AUTH_SYMBOLS.IUsersRepository, + ); + const beforeAlice = await before.getUserByUsername("alice"); + + await bindDevSeedAuth(); + const after = authContainer.get( + AUTH_SYMBOLS.IUsersRepository, + ); + const afterAlice = await after.getUserByUsername("alice"); + + expect(afterAlice?.username).toBe(beforeAlice?.username); + // It's a fresh instance — not the previous one. + expect(after).not.toBe(before); + }); +}); diff --git a/packages/auth/src/di/bind-dev-seed.ts b/packages/auth/src/di/bind-dev-seed.ts new file mode 100644 index 0000000..13f9388 --- /dev/null +++ b/packages/auth/src/di/bind-dev-seed.ts @@ -0,0 +1,33 @@ +import { authContainer } from "./container.js"; +import { AUTH_SYMBOLS } from "./symbols.js"; +import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock.js"; +import { buildDevUsers } from "../__seeds__/dev.js"; +import type { IUsersRepository } from "../application/repositories/users.repository.interface.js"; + +/** + * 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 + * `bindProductionAuth(config)`. Tests must NOT call this — they construct + * `new MockUsersRepository()` directly and seed via factories per-test. + * + * The `IAuthenticationService` binding is left untouched; it resolves users + * through DI from the newly seeded repo. + * + * Idempotent: safe to call multiple times; each call rebuilds a fresh + * populated repo and rebinds the symbol. + */ +export async function bindDevSeedAuth(): Promise { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + + const repo = new MockUsersRepository([]); + for (const user of buildDevUsers()) { + await repo.createUser(user); + } + + authContainer + .bind(AUTH_SYMBOLS.IUsersRepository) + .toConstantValue(repo); +} diff --git a/packages/marketing-pages/package.json b/packages/marketing-pages/package.json index fe57ca9..c1d6b93 100644 --- a/packages/marketing-pages/package.json +++ b/packages/marketing-pages/package.json @@ -8,7 +8,8 @@ "./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-production": "./src/di/bind-production.ts", + "./di/bind-dev-seed": "./src/di/bind-dev-seed.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/marketing-pages/src/__seeds__/dev.ts b/packages/marketing-pages/src/__seeds__/dev.ts new file mode 100644 index 0000000..191fa17 --- /dev/null +++ b/packages/marketing-pages/src/__seeds__/dev.ts @@ -0,0 +1,55 @@ +import { pageFactory } from "../__factories__/page.factory.js"; +import { siteSettingsFactory } from "../__factories__/site-settings.factory.js"; +import type { Page } from "../entities/models/page.js"; +import type { SiteSettings } from "../entities/models/site-settings.js"; + +/** + * Realistic marketing-pages seed for dev mode + storybook stories. + * + * Built from `pageFactory` / `siteSettingsFactory` so factory defaults take + * care of boring fields and we only override what makes the data look like a + * real site. + * + * Lazily produced so importing this module is side-effect-free — the + * factory's sequence counter only advances when a binder calls + * `buildDevPages()` / `buildDevSiteSettings()`. + */ +export function buildDevPages(): Page[] { + const SEED_DATE = new Date("2026-01-01T00:00:00.000Z"); + return [ + pageFactory.build({ + id: "home", + slug: "home", + title: "Home", + hero: { heading: "Welcome to our site" }, + status: "published", + publishedAt: SEED_DATE, + seo: { title: "Home | My App" }, + }), + pageFactory.build({ + id: "about", + slug: "about", + title: "About Us", + hero: { heading: "Our story" }, + status: "published", + publishedAt: SEED_DATE, + seo: { title: "About | My App" }, + }), + pageFactory.build({ + id: "pricing", + slug: "pricing", + title: "Pricing", + hero: { heading: "Simple, transparent pricing" }, + status: "published", + publishedAt: SEED_DATE, + seo: { title: "Pricing | My App" }, + }), + ]; +} + +export function buildDevSiteSettings(): SiteSettings { + return siteSettingsFactory.build({ + siteName: "My App", + siteDescription: "A vertical-feature monorepo template", + }); +} diff --git a/packages/marketing-pages/src/di/bind-dev-seed.test.ts b/packages/marketing-pages/src/di/bind-dev-seed.test.ts new file mode 100644 index 0000000..07e9c1d --- /dev/null +++ b/packages/marketing-pages/src/di/bind-dev-seed.test.ts @@ -0,0 +1,98 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { bindDevSeedMarketingPages } from "@/di/bind-dev-seed"; +import { marketingPagesContainer } from "@/di/container"; +import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols"; +import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock"; +import { MockSiteSettingsRepository } from "@/infrastructure/repositories/site-settings.repository.mock"; +import type { IPagesRepository } from "@/application/repositories/pages.repository.interface"; +import type { ISiteSettingsRepository } from "@/application/repositories/site-settings.repository.interface"; + +describe("bindDevSeedMarketingPages", () => { + // Each test starts from default mock bindings and tears down afterwards so + // the global marketingPagesContainer state stays clean for siblings. + beforeEach(() => { + if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) { + marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); + } + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) + .to(MockPagesRepository); + + if ( + marketingPagesContainer.isBound( + MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, + ) + ) { + marketingPagesContainer.unbind( + MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, + ); + } + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) + .to(MockSiteSettingsRepository); + }); + + afterEach(() => { + if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) { + marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); + } + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) + .to(MockPagesRepository); + + if ( + marketingPagesContainer.isBound( + MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, + ) + ) { + marketingPagesContainer.unbind( + MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, + ); + } + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) + .to(MockSiteSettingsRepository); + }); + + it("populates the pages repository with the dev pages", async () => { + await bindDevSeedMarketingPages(); + + const repo = marketingPagesContainer.get( + MARKETING_PAGES_SYMBOLS.IPagesRepository, + ); + const pages = await repo.getPages(); + + expect(pages.length).toBeGreaterThan(0); + }); + + it("seeds site settings with a non-default site name", async () => { + await bindDevSeedMarketingPages(); + + const settingsRepo = marketingPagesContainer.get( + MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, + ); + const settings = await settingsRepo.getSiteSettings(); + + expect(settings.siteName).toBe("My App"); + expect(settings.siteDescription).toBeDefined(); + }); + + it("is idempotent — calling twice rebuilds fresh populated repos", async () => { + await bindDevSeedMarketingPages(); + const before = marketingPagesContainer.get( + MARKETING_PAGES_SYMBOLS.IPagesRepository, + ); + const beforeCount = (await before.getPages()).length; + + await bindDevSeedMarketingPages(); + const after = marketingPagesContainer.get( + MARKETING_PAGES_SYMBOLS.IPagesRepository, + ); + const afterCount = (await after.getPages()).length; + + expect(afterCount).toBe(beforeCount); + // It's a fresh instance — not the previous one. + expect(after).not.toBe(before); + }); +}); diff --git a/packages/marketing-pages/src/di/bind-dev-seed.ts b/packages/marketing-pages/src/di/bind-dev-seed.ts new file mode 100644 index 0000000..102d699 --- /dev/null +++ b/packages/marketing-pages/src/di/bind-dev-seed.ts @@ -0,0 +1,46 @@ +import { marketingPagesContainer } from "./container.js"; +import { MARKETING_PAGES_SYMBOLS } from "./symbols.js"; +import { MockPagesRepository } from "../infrastructure/repositories/pages.repository.mock.js"; +import { buildDevPages, buildDevSiteSettings } from "../__seeds__/dev.js"; +import type { IPagesRepository } from "../application/repositories/pages.repository.interface.js"; +import type { ISiteSettingsRepository } from "../application/repositories/site-settings.repository.interface.js"; + +/** + * Replace the default empty mocks with populated ones for dev mode + storybook. + * + * Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with + * `bindProductionMarketingPages(config)`. Tests must NOT call this — they + * construct mocks directly and seed via factories per-test. + * + * Idempotent: safe to call multiple times; each call rebuilds fresh populated + * repos and rebinds both symbols. + */ +export async function bindDevSeedMarketingPages(): Promise { + // Pages repository — MockPagesRepository accepts an initial array. + if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) { + marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository); + } + + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.IPagesRepository) + .toConstantValue(new MockPagesRepository(buildDevPages())); + + // Site settings repository — MockSiteSettingsRepository has no mutable + // storage so we bind a constant-value implementation directly. + if ( + marketingPagesContainer.isBound( + MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, + ) + ) { + marketingPagesContainer.unbind( + MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, + ); + } + + const settings = buildDevSiteSettings(); + marketingPagesContainer + .bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository) + .toConstantValue({ + getSiteSettings: async () => settings, + }); +} diff --git a/packages/media/package.json b/packages/media/package.json index 41cfc63..8adc0f7 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -8,7 +8,8 @@ "./ui": "./src/ui/index.ts", "./cms": "./src/integrations/cms/index.ts", "./api": "./src/integrations/api/index.ts", - "./di/bind-production": "./src/di/bind-production.ts" + "./di/bind-production": "./src/di/bind-production.ts", + "./di/bind-dev-seed": "./src/di/bind-dev-seed.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/media/src/__seeds__/dev.ts b/packages/media/src/__seeds__/dev.ts new file mode 100644 index 0000000..222a3d8 --- /dev/null +++ b/packages/media/src/__seeds__/dev.ts @@ -0,0 +1,38 @@ +import { mediaFactory } from "../__factories__/media.factory.js"; +import type { Media } from "../entities/models/media.js"; + +/** + * Realistic media seed for dev mode + storybook stories. + * + * Built from `mediaFactory` so factory defaults take care of boring fields + * and we only override what makes the data look like a real media library. + * + * Lazily produced so importing this module is side-effect-free — the + * factory's sequence counter only advances when a binder calls + * `buildDevMedia()`. + */ +export function buildDevMedia(): Media[] { + return [ + mediaFactory.build({ + id: "placeholder-1", + alt: "Hero background image", + url: "/dev/placeholder-1.jpg", + filename: "placeholder-1.jpg", + mimeType: "image/jpeg", + }), + mediaFactory.build({ + id: "placeholder-2", + alt: "Team photo", + url: "/dev/placeholder-2.jpg", + filename: "placeholder-2.jpg", + mimeType: "image/jpeg", + }), + mediaFactory.build({ + id: "placeholder-3", + alt: "Product screenshot", + url: "/dev/placeholder-3.png", + filename: "placeholder-3.png", + mimeType: "image/png", + }), + ]; +} diff --git a/packages/media/src/di/bind-dev-seed.test.ts b/packages/media/src/di/bind-dev-seed.test.ts new file mode 100644 index 0000000..b5c07b7 --- /dev/null +++ b/packages/media/src/di/bind-dev-seed.test.ts @@ -0,0 +1,71 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { bindDevSeedMedia } from "@/di/bind-dev-seed"; +import { mediaContainer } from "@/di/container"; +import { MEDIA_SYMBOLS } from "@/di/symbols"; +import { MockMediaRepository } from "@/infrastructure/repositories/media.repository.mock"; +import type { IMediaRepository } from "@/application/repositories/media.repository.interface"; + +describe("bindDevSeedMedia", () => { + // Each test starts from the default empty-mock binding and tears down + // afterwards so the global mediaContainer state stays clean for siblings. + beforeEach(() => { + if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) { + mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository); + } + mediaContainer + .bind(MEDIA_SYMBOLS.IMediaRepository) + .to(MockMediaRepository); + }); + + afterEach(() => { + if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) { + mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository); + } + mediaContainer + .bind(MEDIA_SYMBOLS.IMediaRepository) + .to(MockMediaRepository); + }); + + it("populates the repository with the dev media entries", async () => { + await bindDevSeedMedia(); + + const repo = mediaContainer.get( + MEDIA_SYMBOLS.IMediaRepository, + ); + const all = await repo.listMedia(); + + expect(all.length).toBeGreaterThan(0); + }); + + it("seeds placeholder-1 reachable by id", async () => { + await bindDevSeedMedia(); + + const repo = mediaContainer.get( + MEDIA_SYMBOLS.IMediaRepository, + ); + const item = await repo.getMedia("placeholder-1"); + + expect(item).toBeDefined(); + expect(item?.url).toBe("/dev/placeholder-1.jpg"); + expect(item?.alt).toBe("Hero background image"); + }); + + it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { + await bindDevSeedMedia(); + const before = mediaContainer.get( + MEDIA_SYMBOLS.IMediaRepository, + ); + const beforeCount = (await before.listMedia()).length; + + await bindDevSeedMedia(); + const after = mediaContainer.get( + MEDIA_SYMBOLS.IMediaRepository, + ); + const afterCount = (await after.listMedia()).length; + + expect(afterCount).toBe(beforeCount); + // It's a fresh instance — not the previous one. + expect(after).not.toBe(before); + }); +}); diff --git a/packages/media/src/di/bind-dev-seed.ts b/packages/media/src/di/bind-dev-seed.ts new file mode 100644 index 0000000..9b400e4 --- /dev/null +++ b/packages/media/src/di/bind-dev-seed.ts @@ -0,0 +1,30 @@ +import { mediaContainer } from "./container.js"; +import { MEDIA_SYMBOLS } from "./symbols.js"; +import { MockMediaRepository } from "../infrastructure/repositories/media.repository.mock.js"; +import { buildDevMedia } from "../__seeds__/dev.js"; +import type { IMediaRepository } from "../application/repositories/media.repository.interface.js"; + +/** + * Replace the default empty mock with a populated one for dev mode + storybook. + * + * Call this from app boot when `USE_DEV_SEED=true`, mutually exclusive with + * `bindProductionMedia(config)`. Tests must NOT call this — they construct + * `new MockMediaRepository()` 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 bindDevSeedMedia(): Promise { + if (mediaContainer.isBound(MEDIA_SYMBOLS.IMediaRepository)) { + mediaContainer.unbind(MEDIA_SYMBOLS.IMediaRepository); + } + + const repo = new MockMediaRepository(); + for (const media of buildDevMedia()) { + await repo._store(media); + } + + mediaContainer + .bind(MEDIA_SYMBOLS.IMediaRepository) + .toConstantValue(repo); +} diff --git a/packages/navigation/package.json b/packages/navigation/package.json index f79478d..bb53384 100644 --- a/packages/navigation/package.json +++ b/packages/navigation/package.json @@ -8,7 +8,8 @@ "./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-production": "./src/di/bind-production.ts", + "./di/bind-dev-seed": "./src/di/bind-dev-seed.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/navigation/src/__seeds__/dev.ts b/packages/navigation/src/__seeds__/dev.ts new file mode 100644 index 0000000..8e028c3 --- /dev/null +++ b/packages/navigation/src/__seeds__/dev.ts @@ -0,0 +1,26 @@ +import { headerFactory } from "../__factories__/header.factory.js"; +import { navItemFactory } from "../__factories__/nav-item.factory.js"; +import type { Header } from "../entities/models/header.js"; + +/** + * 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 }), + ], + }); +} diff --git a/packages/navigation/src/di/bind-dev-seed.test.ts b/packages/navigation/src/di/bind-dev-seed.test.ts new file mode 100644 index 0000000..4bfb1b4 --- /dev/null +++ b/packages/navigation/src/di/bind-dev-seed.test.ts @@ -0,0 +1,72 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +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"; + +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(NAVIGATION_SYMBOLS.IHeaderRepository) + .to(MockHeaderRepository); + }); + + afterEach(() => { + if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { + navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); + } + navigationContainer + .bind(NAVIGATION_SYMBOLS.IHeaderRepository) + .to(MockHeaderRepository); + }); + + it("populates the header repository with the dev header", async () => { + await bindDevSeedNavigation(); + + const repo = navigationContainer.get( + NAVIGATION_SYMBOLS.IHeaderRepository, + ); + const header = await repo.getHeader(); + + expect(header).toBeDefined(); + }); + + it("seeds a header with a non-empty items array", async () => { + await bindDevSeedNavigation(); + + 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(homeItem).toBeDefined(); + expect(homeItem?.href).toBe("/"); + }); + + it("is idempotent — calling twice rebuilds a fresh populated repo", async () => { + await bindDevSeedNavigation(); + const before = navigationContainer.get( + NAVIGATION_SYMBOLS.IHeaderRepository, + ); + const beforeHeader = await before.getHeader(); + + await bindDevSeedNavigation(); + const after = navigationContainer.get( + 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); + }); +}); diff --git a/packages/navigation/src/di/bind-dev-seed.ts b/packages/navigation/src/di/bind-dev-seed.ts new file mode 100644 index 0000000..626963d --- /dev/null +++ b/packages/navigation/src/di/bind-dev-seed.ts @@ -0,0 +1,26 @@ +import { navigationContainer } from "./container.js"; +import { NAVIGATION_SYMBOLS } from "./symbols.js"; +import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock.js"; +import { buildDevHeader } from "../__seeds__/dev.js"; +import type { IHeaderRepository } from "../application/repositories/header.repository.interface.js"; + +/** + * 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(): Promise { + if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { + navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); + } + + navigationContainer + .bind(NAVIGATION_SYMBOLS.IHeaderRepository) + .toConstantValue(new MockHeaderRepository(buildDevHeader())); +}