/* Unit tests for actions covering its documented public contract and regression-sensitive edge cases. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-0290: initial action interpreter tests. RFC-1311: carried legacy entry — Post-refactor hardening: cover prototype-key unknown property validation. RFC-1186: step 4 — manifest bound set, formBinding stamp, gate formId tagging */ import { test, expect } from "vitest"; import { validateAgainstCapabilitySchema, buildIntegrationEventFromAction } from "../actions.ts"; import type { CapabilityRecord, CapabilityInputOutputSchema, } from "@warpgogol/werkstatt-shared/ontology"; const schema: CapabilityInputOutputSchema = { type: "object", required: ["message"], additionalProperties: false, properties: { message: { type: "string", minLength: 10, maxLength: 4000 }, email: { type: "string", format: "email" }, eventId: { type: "string", format: "uuid" }, accepted: { type: "boolean" }, count: { type: "integer" }, }, }; test("validateAgainstCapabilitySchema: rejects non-object input", () => { const result = validateAgainstCapabilitySchema(schema, "nope"); expect(result.ok).toBe(false); }); test("validateAgainstCapabilitySchema: rejects unknown properties", () => { const result = validateAgainstCapabilitySchema(schema, { message: "hello there", extra: 1 }); expect(result.ok).toBe(false); if (!result.ok) expect(result.errors.some((e) => e.path === "extra")).toBeTruthy(); }); test("validateAgainstCapabilitySchema: treats prototype property names as unknown input keys", () => { const result = validateAgainstCapabilitySchema(schema, { message: "hello there", toString: "not schema-owned", }); expect(result.ok).toBe(false); if (!result.ok) expect(result.errors.some((e) => e.path === "toString")).toBeTruthy(); }); test("validateAgainstCapabilitySchema: reports missing required fields", () => { const result = validateAgainstCapabilitySchema(schema, {}); expect(result.ok).toBe(false); if (!result.ok) expect(result.errors).toEqual([{ path: "message", message: "required", code: "REQUIRED" }]); }); test("validateAgainstCapabilitySchema: enforces minLength/maxLength", () => { const tooShort = validateAgainstCapabilitySchema(schema, { message: "short" }); expect(tooShort.ok).toBe(false); const ok = validateAgainstCapabilitySchema(schema, { message: "a".repeat(20) }); expect(ok.ok).toBe(true); }); test("validateAgainstCapabilitySchema: enforces email format", () => { const bad = validateAgainstCapabilitySchema(schema, { message: "hello there!!", email: "not-an-email", }); expect(bad.ok).toBe(false); const good = validateAgainstCapabilitySchema(schema, { message: "hello there!!", email: "a@b.com", }); expect(good.ok).toBe(true); }); test("validateAgainstCapabilitySchema: enforces type checks (boolean, integer)", () => { const badBool = validateAgainstCapabilitySchema(schema, { message: "hello there!!", accepted: "yes", }); expect(badBool.ok).toBe(false); const badInt = validateAgainstCapabilitySchema(schema, { message: "hello there!!", count: 1.5 }); expect(badInt.ok).toBe(false); const good = validateAgainstCapabilitySchema(schema, { message: "hello there!!", accepted: true, count: 3, }); expect(good.ok).toBe(true); }); function makeCapability(): CapabilityRecord { return { id: "lead.submit", version: 1, kind: "action", title: { de: "x" }, description: { de: "x" }, input: schema, output: schema, integration: { eventKind: "lead", source: "agent" }, requires: { entitlements: [], sections: ["send-message"] }, humanEquivalent: { sectionType: "send-message" }, limits: { perMinutePerIp: 10, maxPayloadBytes: 16384 }, }; } test("buildIntegrationEventFromAction: extracts contact fields and keeps full payload", () => { const cap = makeCapability(); const now = new Date("2026-07-05T00:00:00.000Z"); const event = buildIntegrationEventFromAction( cap, { message: "hello there!!", email: "a@b.com" }, "de", now, ); expect(event.kind).toBe("lead"); expect(event.source).toBe("agent"); expect(event.locale).toBe("de"); expect(event.occurredAt).toBe("2026-07-05T00:00:00.000Z"); expect(event.contact).toEqual({ email: "a@b.com" }); expect(event.payload).toEqual({ message: "hello there!!", email: "a@b.com" }); expect(event.eventId).toMatch(/^[0-9a-f-]{36}$/); }); test("buildIntegrationEventFromAction: ignores a client-supplied eventId field", () => { const cap = makeCapability(); const clientId = "11111111-1111-1111-1111-111111111111"; const event = buildIntegrationEventFromAction( cap, { message: "hello there!!", eventId: clientId }, "de", new Date(), ); // RFC-1112: eventId is never client-controlled — random UUID without a key. expect(event.eventId).not.toBe(clientId); expect(event.eventId).toMatch(/^[0-9a-f-]{36}$/); }); test("buildIntegrationEventFromAction: bound capability tags payload.formId", () => { const cap = { ...makeCapability(), id: "recommendation.submit", integration: { eventKind: "recommendation", source: "agent" }, formBinding: { formId: "recommendation-form" }, } as CapabilityRecord; const event = buildIntegrationEventFromAction( cap, { message: "a great fit for the directory" }, "de", new Date("2026-10-03T00:00:00.000Z"), ); expect(event.kind).toBe("recommendation"); expect( event.payload.formId, "a bound capability must stamp payload.formId from the generated formBinding — check agent-gate/actions.ts SiteBoundCapability handling", ).toBe("recommendation-form"); }); test("buildIntegrationEventFromAction: unbound capability leaves payload untouched", () => { const cap = makeCapability(); const event = buildIntegrationEventFromAction( cap, { message: "hello there!!" }, "de", new Date(), ); expect(event.payload).not.toHaveProperty("formId"); expect(event.payload).toEqual({ message: "hello there!!" }); }); test("buildIntegrationEventFromAction: idempotency key derives a deterministic eventId", () => { const cap = makeCapability(); const a = buildIntegrationEventFromAction( cap, { message: "hello there!!" }, "de", new Date(), "key-1", ); const b = buildIntegrationEventFromAction( cap, { message: "hello there!!" }, "de", new Date(), "key-1", ); const c = buildIntegrationEventFromAction( cap, { message: "hello there!!" }, "de", new Date(), "key-2", ); expect(a.eventId).toBe(b.eventId); expect(a.eventId).not.toBe(c.eventId); expect(a.eventId).toMatch(/^[0-9a-f-]{36}$/); });