feat(features): contract suites for all repository interfaces
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
This commit is contained in:
@@ -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>(
|
||||
"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");
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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>(
|
||||
"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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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<Page | undefined> {
|
||||
return this._pages.find((p) => p.slug === slug);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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<string, Record<string, unknown>>(
|
||||
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<typeof vi.fn>).mockResolvedValue(stub);
|
||||
return new PayloadPagesRepository(stubPayloadConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {
|
||||
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<typeof vi.fn>).mockResolvedValue(stub);
|
||||
return new PayloadSiteSettingsRepository(stubPayloadConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user