/** * Contract Guard Tests */ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdir, rm, writeFile } from "fs/promises"; import path from "path"; import { checkMissingContract, checkContractFileExists, checkContractSlotConsistency, runContractGuardCheck, } from "./contract-guard"; import type { RoutesManifest } from "../spec/schema"; const TEST_DIR = path.join(process.cwd(), ".test-guard"); describe("Contract Guard", () => { beforeEach(async () => { await mkdir(TEST_DIR, { recursive: true }); await mkdir(path.join(TEST_DIR, "spec/contracts"), { recursive: true }); await mkdir(path.join(TEST_DIR, "spec/slots"), { recursive: true }); }); afterEach(async () => { await rm(TEST_DIR, { recursive: true, force: true }); }); describe("checkMissingContract", () => { test("should detect API routes without contracts", async () => { const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", slotModule: "spec/slots/users.slot.ts", // No contractModule }, ], }; const violations = await checkMissingContract(manifest, TEST_DIR); expect(violations.length).toBe(1); expect(violations[0].ruleId).toBe("CONTRACT_MISSING"); expect(violations[0].routeId).toBe("users"); }); test("should pass API routes with contracts", async () => { const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", slotModule: "spec/slots/users.slot.ts", contractModule: "spec/contracts/users.contract.ts", }, ], }; const violations = await checkMissingContract(manifest, TEST_DIR); expect(violations.length).toBe(0); }); test("should skip non-API routes", async () => { const manifest: RoutesManifest = { version: 1, routes: [ { id: "home", pattern: "/", kind: "page", module: "generated/routes/home.ts", componentModule: "generated/routes/home.tsx", slotModule: "spec/slots/home.slot.ts", // No contractModule - but that's fine for pages }, ], }; const violations = await checkMissingContract(manifest, TEST_DIR); expect(violations.length).toBe(0); }); }); describe("checkContractFileExists", () => { test("should detect missing contract files", async () => { const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", contractModule: "spec/contracts/users.contract.ts", }, ], }; const violations = await checkContractFileExists(manifest, TEST_DIR); expect(violations.length).toBe(1); expect(violations[0].ruleId).toBe("CONTRACT_NOT_FOUND"); }); test("should pass when contract file exists", async () => { // Create contract file await writeFile( path.join(TEST_DIR, "spec/contracts/users.contract.ts"), `export default { request: {}, response: {} }` ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", contractModule: "spec/contracts/users.contract.ts", }, ], }; const violations = await checkContractFileExists(manifest, TEST_DIR); expect(violations.length).toBe(0); }); }); describe("checkContractSlotConsistency", () => { test("should detect methods in contract but not in slot", async () => { // Create contract with GET and POST await writeFile( path.join(TEST_DIR, "spec/contracts/users.contract.ts"), ` export default { request: { GET: { query: {} }, POST: { body: {} }, DELETE: {}, }, response: {}, }; ` ); // Create slot with only GET await writeFile( path.join(TEST_DIR, "spec/slots/users.slot.ts"), ` export default Mandu.filling() .get((ctx) => ctx.ok({ data: [] })); ` ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", contractModule: "spec/contracts/users.contract.ts", slotModule: "spec/slots/users.slot.ts", }, ], }; const violations = await checkContractSlotConsistency(manifest, TEST_DIR); expect(violations.length).toBe(1); expect(violations[0].ruleId).toBe("CONTRACT_METHOD_NOT_IMPLEMENTED"); expect(violations[0].missingMethods).toContain("POST"); expect(violations[0].missingMethods).toContain("DELETE"); }); test("should detect methods in slot but not in contract", async () => { // Create contract with only GET await writeFile( path.join(TEST_DIR, "spec/contracts/users.contract.ts"), ` export default { request: { GET: { query: {} }, }, response: {}, }; ` ); // Create slot with GET, POST, and DELETE await writeFile( path.join(TEST_DIR, "spec/slots/users.slot.ts"), ` export default Mandu.filling() .get((ctx) => ctx.ok({ data: [] })) .post((ctx) => ctx.created({})) .delete((ctx) => ctx.noContent()); ` ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", contractModule: "spec/contracts/users.contract.ts", slotModule: "spec/slots/users.slot.ts", }, ], }; const violations = await checkContractSlotConsistency(manifest, TEST_DIR); expect(violations.length).toBe(1); expect(violations[0].ruleId).toBe("CONTRACT_METHOD_UNDOCUMENTED"); expect(violations[0].undocumentedMethods).toContain("POST"); expect(violations[0].undocumentedMethods).toContain("DELETE"); }); test("should pass when contract and slot are in sync", async () => { // Create contract with GET and POST await writeFile( path.join(TEST_DIR, "spec/contracts/users.contract.ts"), ` export default { request: { GET: { query: {} }, POST: { body: {} }, }, response: {}, }; ` ); // Create slot with GET and POST await writeFile( path.join(TEST_DIR, "spec/slots/users.slot.ts"), ` export default Mandu.filling() .get((ctx) => ctx.ok({ data: [] })) .post((ctx) => ctx.created({})); ` ); const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", contractModule: "spec/contracts/users.contract.ts", slotModule: "spec/slots/users.slot.ts", }, ], }; const violations = await checkContractSlotConsistency(manifest, TEST_DIR); expect(violations.length).toBe(0); }); }); describe("runContractGuardCheck", () => { test("should run all checks", async () => { // Missing contract file const manifest: RoutesManifest = { version: 1, routes: [ { id: "users", pattern: "/api/users", kind: "api", module: "generated/routes/api/users.ts", contractModule: "spec/contracts/users.contract.ts", slotModule: "spec/slots/users.slot.ts", }, ], }; const violations = await runContractGuardCheck(manifest, TEST_DIR); // Should find CONTRACT_NOT_FOUND expect(violations.some((v) => v.ruleId === "CONTRACT_NOT_FOUND")).toBe(true); }); }); });