From e1355e6bc7ed21cc3f4cd0b159f8899e3e358dc3 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Tue, 5 May 2026 15:28:38 +0200 Subject: [PATCH] feat(features): contract suites for all repository interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each repository interface now has a contract suite under src/__contracts__/. Both Mock and Payload implementations run the same suite, eliminating mock-vs-real drift. Payload impls back the contract with an in-memory stub via vi.mock('payload') + a small buildPayloadStub helper. Spec: §5.2, §6.4 --- .../users-repository.contract.ts | 56 +++++ .../mock-users.repository.test.ts | 8 + .../repositories/mock-users.repository.ts | 14 +- .../articles-repository.contract.ts | 120 +++++++++++ .../mock-articles.repository.test.ts | 7 + .../payload-articles.repository.test.ts | 196 ++++++++++++++---- packages/core-testing/package.json | 1 + .../pages-repository.contract.ts | 99 +++++++++ .../site-settings-repository.contract.ts | 38 ++++ .../mock-pages.repository.test.ts | 12 ++ .../repositories/mock-pages.repository.ts | 34 +-- .../mock-site-settings.repository.test.ts | 7 + .../payload-pages.repository.test.ts | 83 ++++++++ .../payload-site-settings.repository.test.ts | 40 ++++ .../header-repository.contract.ts | 47 +++++ .../mock-header.repository.test.ts | 7 + .../payload-header.repository.test.ts | 44 ++++ 17 files changed, 751 insertions(+), 62 deletions(-) create mode 100644 packages/auth/src/__contracts__/users-repository.contract.ts create mode 100644 packages/auth/src/infrastructure/repositories/mock-users.repository.test.ts create mode 100644 packages/blog/src/__contracts__/articles-repository.contract.ts create mode 100644 packages/blog/src/infrastructure/repositories/mock-articles.repository.test.ts create mode 100644 packages/marketing-pages/src/__contracts__/pages-repository.contract.ts create mode 100644 packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts create mode 100644 packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.test.ts create mode 100644 packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.test.ts create mode 100644 packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.test.ts create mode 100644 packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.test.ts create mode 100644 packages/navigation/src/__contracts__/header-repository.contract.ts create mode 100644 packages/navigation/src/infrastructure/repositories/mock-header.repository.test.ts create mode 100644 packages/navigation/src/infrastructure/repositories/payload-header.repository.test.ts diff --git a/packages/auth/src/__contracts__/users-repository.contract.ts b/packages/auth/src/__contracts__/users-repository.contract.ts new file mode 100644 index 0000000..67ff9e3 --- /dev/null +++ b/packages/auth/src/__contracts__/users-repository.contract.ts @@ -0,0 +1,56 @@ +import { it, expect, beforeEach } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { IUsersRepository } from "../application/repositories/users-repository.interface.js"; +import { userFactory } from "../__factories__/user.factory.js"; + +export const usersRepositoryContract = + defineContractSuite( + "IUsersRepository", + ({ buildSubject }) => { + let repo: IUsersRepository; + + beforeEach(async () => { + userFactory.reset(); + repo = await buildSubject(); + }); + + // --- getUser --- + + it("createUser then getUser returns it by id", async () => { + const seed = userFactory.build(); + await repo.createUser(seed); + const result = await repo.getUser(seed.id); + expect(result?.id).toBe(seed.id); + expect(result?.username).toBe(seed.username); + }); + + it("getUser returns undefined for missing id", async () => { + expect(await repo.getUser("does-not-exist")).toBeUndefined(); + }); + + // --- getUserByUsername --- + + it("createUser then getUserByUsername returns it by username", async () => { + const seed = userFactory.build({ username: "alice" }); + await repo.createUser(seed); + const result = await repo.getUserByUsername("alice"); + expect(result?.id).toBe(seed.id); + expect(result?.username).toBe("alice"); + }); + + it("getUserByUsername returns undefined for missing username", async () => { + expect( + await repo.getUserByUsername("no-such-user"), + ).toBeUndefined(); + }); + + // --- createUser --- + + it("createUser returns the created user", async () => { + const seed = userFactory.build({ username: "carol" }); + const created = await repo.createUser(seed); + expect(created.id).toBe(seed.id); + expect(created.username).toBe("carol"); + }); + }, + ); diff --git a/packages/auth/src/infrastructure/repositories/mock-users.repository.test.ts b/packages/auth/src/infrastructure/repositories/mock-users.repository.test.ts new file mode 100644 index 0000000..271d2fe --- /dev/null +++ b/packages/auth/src/infrastructure/repositories/mock-users.repository.test.ts @@ -0,0 +1,8 @@ +import { describe } from "vitest"; +import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository"; +import { usersRepositoryContract } from "@/__contracts__/users-repository.contract"; + +describe("MockUsersRepository", () => { + // Start with empty store so contract tests run from a clean slate. + usersRepositoryContract.run(() => new MockUsersRepository([])); +}); diff --git a/packages/auth/src/infrastructure/repositories/mock-users.repository.ts b/packages/auth/src/infrastructure/repositories/mock-users.repository.ts index 4c4f194..703416a 100644 --- a/packages/auth/src/infrastructure/repositories/mock-users.repository.ts +++ b/packages/auth/src/infrastructure/repositories/mock-users.repository.ts @@ -4,12 +4,18 @@ import { injectable } from "inversify"; import type { IUsersRepository } from "../../application/repositories/users-repository.interface"; import type { User } from "../../entities/user"; +const DEFAULT_SEED: User[] = [ + { id: "1", username: "alice", passwordHash: "hashed_password_alice" }, + { id: "2", username: "bob", passwordHash: "hashed_password_bob" }, +]; + @injectable() export class MockUsersRepository implements IUsersRepository { - private _users: User[] = [ - { id: "1", username: "alice", passwordHash: "hashed_password_alice" }, - { id: "2", username: "bob", passwordHash: "hashed_password_bob" }, - ]; + private _users: User[]; + + constructor(initialUsers: User[] = DEFAULT_SEED) { + this._users = [...initialUsers]; + } async getUser(id: string): Promise { return this._users.find((u) => u.id === id); diff --git a/packages/blog/src/__contracts__/articles-repository.contract.ts b/packages/blog/src/__contracts__/articles-repository.contract.ts new file mode 100644 index 0000000..80a0c29 --- /dev/null +++ b/packages/blog/src/__contracts__/articles-repository.contract.ts @@ -0,0 +1,120 @@ +import { it, expect, beforeEach } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { IArticlesRepository } from "../application/repositories/articles-repository.interface.js"; +import { articleFactory } from "../__factories__/article.factory.js"; + +export const articlesRepositoryContract = + defineContractSuite( + "IArticlesRepository", + ({ buildSubject }) => { + let repo: IArticlesRepository; + + beforeEach(async () => { + articleFactory.reset(); + repo = await buildSubject(); + }); + + // --- createArticle --- + + it("createArticle returns an article with an id and the correct fields", async () => { + const seed = articleFactory.build({ title: "Hello World" }); + const created = await repo.createArticle(seed); + // Implementations may assign their own id (e.g. Payload), so we only + // verify the id is a non-empty string and the other fields match. + expect(typeof created.id).toBe("string"); + expect(created.id.length).toBeGreaterThan(0); + expect(created.title).toBe("Hello World"); + expect(created.slug).toBe(seed.slug); + expect(created.status).toBe(seed.status); + expect(created.authorId).toBe(seed.authorId); + }); + + // --- getArticle --- + + it("createArticle then getArticle returns it by the returned id", async () => { + const seed = articleFactory.build(); + const created = await repo.createArticle(seed); + // Use the id returned by createArticle (Payload may differ from seed.id) + const result = await repo.getArticle(created.id); + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.slug).toBe(seed.slug); + }); + + it("getArticle returns undefined for missing id", async () => { + expect(await repo.getArticle("does-not-exist")).toBeUndefined(); + }); + + // --- getArticleBySlug --- + + it("createArticle then getArticleBySlug returns it by slug", async () => { + const seed = articleFactory.build({ slug: "my-slug" }); + const created = await repo.createArticle(seed); + const result = await repo.getArticleBySlug("my-slug"); + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.slug).toBe("my-slug"); + }); + + it("getArticleBySlug returns undefined for missing slug", async () => { + expect(await repo.getArticleBySlug("does-not-exist")).toBeUndefined(); + }); + + // --- getArticles --- + + it("getArticles returns empty array when no articles", async () => { + const list = await repo.getArticles(); + expect(list).toHaveLength(0); + }); + + it("getArticles returns all articles when no filter", async () => { + await repo.createArticle(articleFactory.build()); + await repo.createArticle(articleFactory.build()); + const list = await repo.getArticles(); + expect(list).toHaveLength(2); + }); + + it("getArticles filters by status", async () => { + await repo.createArticle(articleFactory.build({ status: "draft" })); + await repo.createArticle( + articleFactory.build({ status: "published" }), + ); + const drafts = await repo.getArticles({ status: "draft" }); + expect(drafts).toHaveLength(1); + expect(drafts[0]?.status).toBe("draft"); + }); + + it("getArticles filters by authorId", async () => { + await repo.createArticle( + articleFactory.build({ authorId: "author-a" }), + ); + await repo.createArticle( + articleFactory.build({ authorId: "author-b" }), + ); + const result = await repo.getArticles({ authorId: "author-a" }); + expect(result).toHaveLength(1); + expect(result[0]?.authorId).toBe("author-a"); + }); + + // --- updateArticle --- + + it("updateArticle changes fields and returns updated article", async () => { + const seed = articleFactory.build({ status: "draft" }); + const created = await repo.createArticle(seed); + // Use the returned id for the update lookup + const updated = await repo.updateArticle(created.id, { + status: "published", + }); + expect(updated).toBeDefined(); + expect(updated?.id).toBe(created.id); + expect(updated?.status).toBe("published"); + }); + + it("updateArticle returns undefined for missing id", async () => { + const result = await repo.updateArticle("no-such-id", { + title: "new", + }); + expect(result).toBeUndefined(); + }); + }, + ); diff --git a/packages/blog/src/infrastructure/repositories/mock-articles.repository.test.ts b/packages/blog/src/infrastructure/repositories/mock-articles.repository.test.ts new file mode 100644 index 0000000..765a43f --- /dev/null +++ b/packages/blog/src/infrastructure/repositories/mock-articles.repository.test.ts @@ -0,0 +1,7 @@ +import { describe } from "vitest"; +import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository"; +import { articlesRepositoryContract } from "@/__contracts__/articles-repository.contract"; + +describe("MockArticlesRepository", () => { + articlesRepositoryContract.run(() => new MockArticlesRepository()); +}); diff --git a/packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts b/packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts index e40141d..6d96158 100644 --- a/packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts +++ b/packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts @@ -1,57 +1,165 @@ -import { describe, expect, it, vi } from "vitest"; -import { PayloadArticlesRepository } from "./payload-articles.repository"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { PayloadArticlesRepository } from "@/infrastructure/repositories/payload-articles.repository"; +import { articlesRepositoryContract } from "@/__contracts__/articles-repository.contract"; +import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; + +// --------------------------------------------------------------------------- +// In-memory Payload stub used by both the contract suite and impl-specific tests +// --------------------------------------------------------------------------- + +function buildPayloadStub() { + const store = new Map>(); + + return { + create: vi.fn( + async ({ + data, + }: { + collection: string; + data: Record; + overrideAccess?: boolean; + }) => { + // Payload assigns an id; here we require the data to carry one + // (the repository passes `id` via the mapped domain object implicitly + // through the Article — we expose the mapped doc back from createArticle). + // The stub returns the data as-is so the mapDoc function can work. + const doc = { id: `stub-${store.size + 1}`, ...data }; + store.set(String(doc.id), doc); + return doc; + }, + ), + find: vi.fn( + async ({ + where, + limit, + }: { + collection: string; + where?: { + slug?: { equals: string }; + status?: { equals: string }; + author?: { equals: string }; + }; + limit?: number; + page?: number; + overrideAccess?: boolean; + }) => { + let docs = Array.from(store.values()); + if (where?.slug) { + docs = docs.filter((d) => d.slug === where.slug?.equals); + } + if (where?.status) { + docs = docs.filter((d) => d.status === where.status?.equals); + } + if (where?.author) { + docs = docs.filter((d) => d.author === where.author?.equals); + } + if (limit !== undefined) { + docs = docs.slice(0, limit); + } + return { docs }; + }, + ), + findByID: vi.fn( + async ({ id }: { collection: string; id: string; overrideAccess?: boolean }) => { + const doc = store.get(String(id)); + if (!doc) throw new Error(`Not found: ${id}`); + return doc; + }, + ), + update: vi.fn( + async ({ + id, + data, + }: { + collection: string; + id: string; + data: Record; + overrideAccess?: boolean; + }) => { + const existing = store.get(String(id)); + if (!existing) throw new Error(`Not found: ${id}`); + const updated = { ...existing, ...data }; + store.set(String(id), updated); + return updated; + }, + ), + }; +} vi.mock("payload", () => ({ getPayload: vi.fn(), })); +// --------------------------------------------------------------------------- +// Contract suite +// --------------------------------------------------------------------------- + describe("PayloadArticlesRepository", () => { - const mockConfig = {} as never; - - it("maps a Payload doc to a domain Article on getArticleBySlug", async () => { - const { getPayload } = await import("payload"); - const findMock = vi.fn().mockResolvedValue({ - docs: [ - { - id: "p-123", - title: "Hello", - slug: "hello", - content: { type: "doc", children: [] }, - status: "published", - author: "u1", - createdAt: "2026-05-04T12:00:00.000Z", - updatedAt: "2026-05-04T12:00:00.000Z", - }, - ], + describe("contract", () => { + articlesRepositoryContract.run(async () => { + const stub = buildPayloadStub(); + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + return new PayloadArticlesRepository(stubPayloadConfig); }); - (getPayload as ReturnType).mockResolvedValue({ - find: findMock, - }); - - const repo = new PayloadArticlesRepository(mockConfig); - const result = await repo.getArticleBySlug("hello"); - - expect(findMock).toHaveBeenCalledWith({ - collection: "articles", - where: { slug: { equals: "hello" } }, - limit: 1, - overrideAccess: true, - }); - expect(result?.id).toBe("p-123"); - expect(result?.slug).toBe("hello"); - expect(result?.status).toBe("published"); - expect(result?.authorId).toBe("u1"); - expect(result?.createdAt).toBeInstanceOf(Date); }); - it("returns undefined when slug is not found", async () => { - const { getPayload } = await import("payload"); - (getPayload as ReturnType).mockResolvedValue({ - find: vi.fn().mockResolvedValue({ docs: [] }), + // ------------------------------------------------------------------------- + // Impl-specific tests: Payload doc → domain mapping + // ------------------------------------------------------------------------- + + describe("Payload doc → domain mapping", () => { + const mockConfig = {} as never; + + beforeEach(() => { + vi.clearAllMocks(); }); - const repo = new PayloadArticlesRepository(mockConfig); - const result = await repo.getArticleBySlug("missing"); - expect(result).toBeUndefined(); + it("maps a Payload doc to a domain Article on getArticleBySlug", async () => { + const { getPayload } = await import("payload"); + const findMock = vi.fn().mockResolvedValue({ + docs: [ + { + id: "p-123", + title: "Hello", + slug: "hello", + content: { type: "doc", children: [] }, + status: "published", + author: "u1", + createdAt: "2026-05-04T12:00:00.000Z", + updatedAt: "2026-05-04T12:00:00.000Z", + }, + ], + }); + (getPayload as ReturnType).mockResolvedValue({ + find: findMock, + }); + + const repo = new PayloadArticlesRepository(mockConfig); + const result = await repo.getArticleBySlug("hello"); + + expect(findMock).toHaveBeenCalledWith({ + collection: "articles", + where: { slug: { equals: "hello" } }, + limit: 1, + overrideAccess: true, + }); + expect(result?.id).toBe("p-123"); + expect(result?.slug).toBe("hello"); + expect(result?.status).toBe("published"); + expect(result?.authorId).toBe("u1"); + expect(result?.createdAt).toBeInstanceOf(Date); + }); + + it("returns undefined when slug is not found", async () => { + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue({ + find: vi.fn().mockResolvedValue({ docs: [] }), + }); + + const repo = new PayloadArticlesRepository(mockConfig); + const result = await repo.getArticleBySlug("missing"); + expect(result).toBeUndefined(); + }); }); }); diff --git a/packages/core-testing/package.json b/packages/core-testing/package.json index 2d172ff..01fddaf 100644 --- a/packages/core-testing/package.json +++ b/packages/core-testing/package.json @@ -9,6 +9,7 @@ "./contract": "./src/contract/index.ts", "./react": "./src/react/index.ts", "./payload": "./src/payload/index.ts", + "./payload/stub-config": "./src/payload/stub-config.ts", "./setup/jsdom": "./src/setup/jsdom.ts", "./setup/node": "./src/setup/node.ts" }, diff --git a/packages/marketing-pages/src/__contracts__/pages-repository.contract.ts b/packages/marketing-pages/src/__contracts__/pages-repository.contract.ts new file mode 100644 index 0000000..fd16715 --- /dev/null +++ b/packages/marketing-pages/src/__contracts__/pages-repository.contract.ts @@ -0,0 +1,99 @@ +import { it, expect, beforeEach } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { IPagesRepository } from "../application/repositories/pages-repository.interface.js"; +import type { Page } from "../entities/page.js"; + +const SEED_DATE = new Date("2026-01-01T00:00:00.000Z"); + +/** + * Known fixtures that every implementation's `buildSubject` must pre-seed. + * Exported so that test files can pass them to `MockPagesRepository` or the + * Payload stub without duplicating definitions. + */ +export const CONTRACT_PAGES_SEED: Page[] = [ + { + id: "cp-1", + title: "About", + slug: "about", + hero: { heading: "About us" }, + layout: [], + status: "published", + publishedAt: SEED_DATE, + seo: { title: "About — Site" }, + createdAt: SEED_DATE, + updatedAt: SEED_DATE, + }, + { + id: "cp-2", + title: "Draft Page", + slug: "draft-page", + hero: { heading: "Draft" }, + layout: [], + status: "draft", + publishedAt: null, + seo: { title: "Draft — Site" }, + createdAt: SEED_DATE, + updatedAt: SEED_DATE, + }, +]; + +/** + * Contract for IPagesRepository. + * + * IPagesRepository is read-only (no createPage). Each `buildSubject` + * must return a repo pre-loaded with CONTRACT_PAGES_SEED (two pages: + * one published with slug "about", one draft with slug "draft-page"). + */ +export const pagesRepositoryContract = + defineContractSuite( + "IPagesRepository", + ({ buildSubject }) => { + let repo: IPagesRepository; + + beforeEach(async () => { + repo = await buildSubject(); + }); + + // --- getPageBySlug --- + + it("getPageBySlug returns the published page by slug", async () => { + const result = await repo.getPageBySlug("about"); + expect(result).toBeDefined(); + expect(result?.slug).toBe("about"); + expect(result?.status).toBe("published"); + expect(result?.id).toBeDefined(); + }); + + it("getPageBySlug returns undefined for an unknown slug", async () => { + expect(await repo.getPageBySlug("no-such-page")).toBeUndefined(); + }); + + // --- getPages --- + + it("getPages with no filter returns all seeded pages", async () => { + const list = await repo.getPages(); + expect(list.length).toBeGreaterThanOrEqual(2); + for (const page of list) { + expect(page.id).toBeDefined(); + expect(page.slug).toBeDefined(); + expect(["draft", "published"]).toContain(page.status); + } + }); + + it("getPages({ status: 'published' }) returns only published pages", async () => { + const published = await repo.getPages({ status: "published" }); + expect(published.length).toBeGreaterThanOrEqual(1); + for (const page of published) { + expect(page.status).toBe("published"); + } + }); + + it("getPages({ status: 'draft' }) returns only draft pages", async () => { + const drafts = await repo.getPages({ status: "draft" }); + expect(drafts.length).toBeGreaterThanOrEqual(1); + for (const page of drafts) { + expect(page.status).toBe("draft"); + } + }); + }, + ); diff --git a/packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts b/packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts new file mode 100644 index 0000000..ae92371 --- /dev/null +++ b/packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts @@ -0,0 +1,38 @@ +import { it, expect, beforeEach } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { ISiteSettingsRepository } from "../application/repositories/site-settings-repository.interface.js"; + +/** + * Contract for ISiteSettingsRepository. + * + * SiteSettings is a singleton (Payload Global). The interface exposes + * only getSiteSettings(). The contract verifies the shape of the return value. + */ +export const siteSettingsRepositoryContract = + defineContractSuite( + "ISiteSettingsRepository", + ({ buildSubject }) => { + let repo: ISiteSettingsRepository; + + beforeEach(async () => { + repo = await buildSubject(); + }); + + // --- getSiteSettings --- + + it("getSiteSettings returns an object with a non-empty siteName", async () => { + const settings = await repo.getSiteSettings(); + expect(settings).toBeDefined(); + expect(typeof settings.siteName).toBe("string"); + expect(settings.siteName.length).toBeGreaterThan(0); + }); + + it("getSiteSettings siteDescription is string or undefined", async () => { + const settings = await repo.getSiteSettings(); + expect( + settings.siteDescription === undefined || + typeof settings.siteDescription === "string", + ).toBe(true); + }); + }, + ); diff --git a/packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.test.ts b/packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.test.ts new file mode 100644 index 0000000..637662f --- /dev/null +++ b/packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.test.ts @@ -0,0 +1,12 @@ +import { describe } from "vitest"; +import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository"; +import { + pagesRepositoryContract, + CONTRACT_PAGES_SEED, +} from "@/__contracts__/pages-repository.contract"; + +describe("MockPagesRepository", () => { + // Pre-seed with the contract fixtures so the contract assertions find what + // they expect (an "about" published page and a "draft-page" draft page). + pagesRepositoryContract.run(() => new MockPagesRepository(CONTRACT_PAGES_SEED)); +}); diff --git a/packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts b/packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts index eea22d6..b151794 100644 --- a/packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts +++ b/packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts @@ -6,22 +6,28 @@ import type { Page } from "../../entities/page"; const SEED_DATE = new Date("2026-01-01T00:00:00.000Z"); +const DEFAULT_SEED: Page[] = [ + { + id: "p1", + title: "About", + slug: "about", + hero: { heading: "About us" }, + layout: [], + status: "published", + publishedAt: SEED_DATE, + seo: { title: "About — My App" }, + createdAt: SEED_DATE, + updatedAt: SEED_DATE, + }, +]; + @injectable() export class MockPagesRepository implements IPagesRepository { - private _pages: Page[] = [ - { - id: "p1", - title: "About", - slug: "about", - hero: { heading: "About us" }, - layout: [], - status: "published", - publishedAt: SEED_DATE, - seo: { title: "About — My App" }, - createdAt: SEED_DATE, - updatedAt: SEED_DATE, - }, - ]; + private _pages: Page[]; + + constructor(initialPages: Page[] = DEFAULT_SEED) { + this._pages = [...initialPages]; + } async getPageBySlug(slug: string): Promise { return this._pages.find((p) => p.slug === slug); diff --git a/packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.test.ts b/packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.test.ts new file mode 100644 index 0000000..7eaf866 --- /dev/null +++ b/packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.test.ts @@ -0,0 +1,7 @@ +import { describe } from "vitest"; +import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository"; +import { siteSettingsRepositoryContract } from "@/__contracts__/site-settings-repository.contract"; + +describe("MockSiteSettingsRepository", () => { + siteSettingsRepositoryContract.run(() => new MockSiteSettingsRepository()); +}); diff --git a/packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.test.ts b/packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.test.ts new file mode 100644 index 0000000..7715926 --- /dev/null +++ b/packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.test.ts @@ -0,0 +1,83 @@ +import { describe, vi } from "vitest"; +import { PayloadPagesRepository } from "@/infrastructure/repositories/payload-pages.repository"; +import { + pagesRepositoryContract, + CONTRACT_PAGES_SEED, +} from "@/__contracts__/pages-repository.contract"; +import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; +import type { Page } from "@/entities/page"; + +// --------------------------------------------------------------------------- +// In-memory Payload stub for pages (read-only collection) +// --------------------------------------------------------------------------- + +function buildPayloadPagesStub(seed: Page[]) { + const store = new Map>( + seed.map((p) => [ + p.id, + { + id: p.id, + title: p.title, + slug: p.slug, + hero: p.hero + ? { + heading: p.hero.heading, + subheading: p.hero.subheading, + } + : null, + layout: p.layout, + status: p.status, + publishedAt: p.publishedAt ? p.publishedAt.toISOString() : null, + seo: p.seo ? { title: p.seo.title, description: p.seo.description } : null, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + }, + ]), + ); + + return { + find: vi.fn( + async ({ + where, + limit, + }: { + collection: string; + where?: { slug?: { equals: string }; status?: { equals: string } }; + limit?: number; + page?: number; + overrideAccess?: boolean; + }) => { + let docs = Array.from(store.values()); + if (where?.slug) { + docs = docs.filter((d) => d.slug === where.slug?.equals); + } + if (where?.status) { + docs = docs.filter((d) => d.status === where.status?.equals); + } + if (limit !== undefined) { + docs = docs.slice(0, limit); + } + return { docs }; + }, + ), + }; +} + +vi.mock("payload", () => ({ + getPayload: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Contract suite +// --------------------------------------------------------------------------- + +describe("PayloadPagesRepository", () => { + describe("contract", () => { + pagesRepositoryContract.run(async () => { + const stub = buildPayloadPagesStub(CONTRACT_PAGES_SEED); + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + return new PayloadPagesRepository(stubPayloadConfig); + }); + }); +}); diff --git a/packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.test.ts b/packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.test.ts new file mode 100644 index 0000000..20d2c3c --- /dev/null +++ b/packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.test.ts @@ -0,0 +1,40 @@ +import { describe, vi } from "vitest"; +import { PayloadSiteSettingsRepository } from "@/infrastructure/repositories/payload-site-settings.repository"; +import { siteSettingsRepositoryContract } from "@/__contracts__/site-settings-repository.contract"; +import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; + +// --------------------------------------------------------------------------- +// In-memory Payload stub for site-settings (Global) +// --------------------------------------------------------------------------- + +function buildSiteSettingsStub() { + const globalData: Record = { + siteName: "Contract Site", + siteDescription: "A site for contract testing", + }; + + return { + findGlobal: vi.fn(async () => { + return { ...globalData }; + }), + }; +} + +vi.mock("payload", () => ({ + getPayload: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Contract suite +// --------------------------------------------------------------------------- + +describe("PayloadSiteSettingsRepository", () => { + describe("contract", () => { + siteSettingsRepositoryContract.run(async () => { + const stub = buildSiteSettingsStub(); + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + return new PayloadSiteSettingsRepository(stubPayloadConfig); + }); + }); +}); diff --git a/packages/navigation/src/__contracts__/header-repository.contract.ts b/packages/navigation/src/__contracts__/header-repository.contract.ts new file mode 100644 index 0000000..431dd5e --- /dev/null +++ b/packages/navigation/src/__contracts__/header-repository.contract.ts @@ -0,0 +1,47 @@ +import { it, expect, beforeEach } from "vitest"; +import { defineContractSuite } from "@repo/core-testing/contract"; +import type { IHeaderRepository } from "../application/repositories/header-repository.interface.js"; + +/** + * Contract for IHeaderRepository. + * + * Header is a singleton (Payload Global). The interface exposes only + * getHeader(). The contract verifies the shape of the return value. + */ +export const headerRepositoryContract = + defineContractSuite( + "IHeaderRepository", + ({ buildSubject }) => { + let repo: IHeaderRepository; + + beforeEach(async () => { + repo = await buildSubject(); + }); + + // --- getHeader --- + + it("getHeader returns an object with an items array", async () => { + const header = await repo.getHeader(); + expect(header).toBeDefined(); + expect(header.items).toBeInstanceOf(Array); + }); + + 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); + }); + }, + ); diff --git a/packages/navigation/src/infrastructure/repositories/mock-header.repository.test.ts b/packages/navigation/src/infrastructure/repositories/mock-header.repository.test.ts new file mode 100644 index 0000000..1982e37 --- /dev/null +++ b/packages/navigation/src/infrastructure/repositories/mock-header.repository.test.ts @@ -0,0 +1,7 @@ +import { describe } from "vitest"; +import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository"; +import { headerRepositoryContract } from "@/__contracts__/header-repository.contract"; + +describe("MockHeaderRepository", () => { + headerRepositoryContract.run(() => new MockHeaderRepository()); +}); diff --git a/packages/navigation/src/infrastructure/repositories/payload-header.repository.test.ts b/packages/navigation/src/infrastructure/repositories/payload-header.repository.test.ts new file mode 100644 index 0000000..43e32f9 --- /dev/null +++ b/packages/navigation/src/infrastructure/repositories/payload-header.repository.test.ts @@ -0,0 +1,44 @@ +import { describe, vi } from "vitest"; +import { PayloadHeaderRepository } from "@/infrastructure/repositories/payload-header.repository"; +import { headerRepositoryContract } from "@/__contracts__/header-repository.contract"; +import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; + +// --------------------------------------------------------------------------- +// In-memory Payload stub for header (Global) +// --------------------------------------------------------------------------- + +function buildHeaderStub() { + const globalData = { + logo: null, + items: [ + { label: "Home", href: "/", external: false }, + { label: "Blog", href: "/blog", external: false }, + { label: "About", href: "/about", external: false }, + ], + }; + + return { + findGlobal: vi.fn(async () => { + return { ...globalData }; + }), + }; +} + +vi.mock("payload", () => ({ + getPayload: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Contract suite +// --------------------------------------------------------------------------- + +describe("PayloadHeaderRepository", () => { + describe("contract", () => { + headerRepositoryContract.run(async () => { + const stub = buildHeaderStub(); + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + return new PayloadHeaderRepository(stubPayloadConfig); + }); + }); +});