import { describe, expect, test } from "bun:test"; import { v } from "@wrnexus/validation"; import { defineService, procedure } from "../src/contract.ts"; describe("defineService", () => { test("captures a procedure's input schema, permission and idempotency", () => { const billing = defineService({ name: "billing", procedures: { createInvoice: procedure .input(v.object({ userId: v.string(), amountCents: v.number() })) .output<{ invoiceId: string }>() .permission("invoice:create") .build(), getInvoice: procedure .input(v.object({ invoiceId: v.string() })) .output<{ amountCents: number }>() .idempotent() .build(), }, }); expect(billing.name).toBe("billing"); expect(Object.keys(billing.procedures).sort()).toEqual(["createInvoice", "getInvoice"]); expect(billing.procedures.createInvoice.permission).toBe("invoice:create"); expect(billing.procedures.createInvoice.idempotent).toBeUndefined(); expect(billing.procedures.getInvoice.idempotent).toBe(true); expect(billing.procedures.getInvoice.permission).toBeUndefined(); }); test("the contract is frozen so it cannot drift after definition", () => { const contract = defineService({ name: "demo", procedures: { ping: procedure.build() } }); expect(Object.isFrozen(contract)).toBe(true); expect(Object.isFrozen(contract.procedures)).toBe(true); }); test("rejects a service name that is not a safe path segment", () => { // The name lands in a URL path, so it must not need escaping. for (const name of ["", "has space", "has/slash", "has.dot", "UPPER"]) { expect(() => defineService({ name, procedures: {} })).toThrow(/service name/i); } expect(() => defineService({ name: "billing-v2", procedures: {} })).not.toThrow(); }); test("rejects a procedure name that is not a safe path segment", () => { expect(() => defineService({ name: "demo", procedures: { "bad name": procedure.build() } }), ).toThrow(/procedure name/i); }); test("a hand-built procedure is frozen too, not just builder output", () => { // AnyProcedures accepts any ProcedureDef shape; the guarantee must not // depend on the caller having used procedure.build(). const contract = defineService({ name: "demo", procedures: { ping: { permission: "demo:read" } }, }); expect(Object.isFrozen(contract.procedures.ping)).toBe(true); expect(() => { (contract.procedures.ping as { permission?: string }).permission = "hacked"; }).toThrow(); expect(contract.procedures.ping.permission).toBe("demo:read"); }); test("the builder is immutable — reusing a base does not cross-contaminate", () => { const base = procedure.permission("a:read"); const one = base.idempotent().build(); const two = base.build(); expect(one.idempotent).toBe(true); expect(two.idempotent).toBeUndefined(); expect(two.permission).toBe("a:read"); }); });