diff --git a/test/response-envelope.test.ts b/test/response-envelope.test.ts new file mode 100644 index 0000000..3120a26 --- /dev/null +++ b/test/response-envelope.test.ts @@ -0,0 +1,477 @@ +import { describe, it, expect } from "vitest"; +import { + localEnvelope, + httpEnvelope, + mcpEnvelope, + isResponseEnvelope, + unwrap, + RESPONSE_SOURCES, + ResponseEnvelopeSchema, + ResponseMetaSchema, +} from "../src/response-envelope.js"; +import type { + ResponseEnvelope, + ResponseMeta, + LocalResponseMeta, + HTTPResponseMeta, + MCPResponseMeta, + MCPContentBlock, + MCPAnnotations, + MCPResourceContent, + ResponseSource, +} from "../src/response-envelope.js"; +import { Value } from "@alkdev/typebox/value"; + +describe("localEnvelope", () => { + it("wraps data with correct meta (source, operationId, timestamp)", () => { + const data = { name: "test", count: 42 }; + const envelope = localEnvelope(data, "ns.myOp"); + + expect(envelope.data).toEqual({ name: "test", count: 42 }); + expect(envelope.meta.source).toBe("local"); + expect(envelope.meta.operationId).toBe("ns.myOp"); + expect(typeof envelope.meta.timestamp).toBe("number"); + expect(envelope.meta.timestamp).toBeLessThanOrEqual(Date.now()); + expect(envelope.meta.timestamp).toBeGreaterThan(Date.now() - 1000); + }); + + it("wraps primitive data", () => { + const envelope = localEnvelope("hello", "op.string"); + expect(envelope.data).toBe("hello"); + expect(envelope.meta.source).toBe("local"); + }); + + it("wraps null data", () => { + const envelope = localEnvelope(null, "op.null"); + expect(envelope.data).toBeNull(); + expect(envelope.meta.source).toBe("local"); + }); + + it("wraps undefined data (void handler)", () => { + const envelope = localEnvelope(undefined, "op.void"); + expect(envelope.data).toBeUndefined(); + expect(envelope.meta.source).toBe("local"); + }); + + it("sets a fresh timestamp for each call", () => { + const before = Date.now(); + const envelope = localEnvelope("a", "op.a"); + const after = Date.now(); + expect(envelope.meta.timestamp).toBeGreaterThanOrEqual(before); + expect(envelope.meta.timestamp).toBeLessThanOrEqual(after); + }); +}); + +describe("httpEnvelope", () => { + it("wraps data with correct meta (source: http, statusCode, headers, contentType)", () => { + const data = { result: "ok" }; + const meta = { + statusCode: 200, + headers: { "content-type": "application/json", "x-request-id": "abc123" }, + contentType: "application/json", + }; + const envelope = httpEnvelope(data, meta); + + expect(envelope.data).toEqual({ result: "ok" }); + expect(envelope.meta).toEqual({ + source: "http", + statusCode: 200, + headers: { "content-type": "application/json", "x-request-id": "abc123" }, + contentType: "application/json", + }); + }); + + it("wraps string data with 404 status", () => { + const envelope = httpEnvelope("Not Found", { + statusCode: 404, + headers: {}, + contentType: "text/plain", + }); + + expect(envelope.data).toBe("Not Found"); + expect(envelope.meta.source).toBe("http"); + expect(envelope.meta.statusCode).toBe(404); + }); + + it("preserves all meta fields from input", () => { + const meta = { + statusCode: 201, + headers: { location: "/resource/1" }, + contentType: "application/json", + }; + const envelope = httpEnvelope({ id: 1 }, meta); + + expect(envelope.meta).toEqual({ source: "http", ...meta }); + }); +}); + +describe("mcpEnvelope", () => { + it("wraps data with correct meta (source: mcp, isError, content, structuredContent, _meta)", () => { + const data = { temperature: 72 }; + const content: MCPContentBlock[] = [ + { type: "text", text: "The temperature is 72°F" }, + ]; + const meta = { + isError: false, + content, + structuredContent: { temperature: 72 } as Record, + }; + const envelope = mcpEnvelope(data, meta); + + expect(envelope.data).toEqual({ temperature: 72 }); + expect(envelope.meta).toEqual({ + source: "mcp", + isError: false, + content, + structuredContent: { temperature: 72 }, + }); + }); + + it("wraps data with isError: true and _meta", () => { + const content: MCPContentBlock[] = [ + { type: "text", text: "Tool execution failed" }, + ]; + const envelope = mcpEnvelope(null, { + isError: true, + content, + _meta: { requestId: "req-001" } as Record, + }); + + expect(envelope.data).toBeNull(); + expect(envelope.meta.source).toBe("mcp"); + expect(envelope.meta.isError).toBe(true); + expect(envelope.meta._meta).toEqual({ requestId: "req-001" }); + }); + + it("works with minimal meta (only required fields)", () => { + const content: MCPContentBlock[] = []; + const envelope = mcpEnvelope("result", { + isError: false, + content, + }); + + expect(envelope.data).toBe("result"); + expect(envelope.meta.structuredContent).toBeUndefined(); + expect(envelope.meta._meta).toBeUndefined(); + }); + + it("wraps structuredContent as data when provided", () => { + const structured = { key: "value" } as Record; + const content: MCPContentBlock[] = [ + { type: "text", text: "result" }, + ]; + const envelope = mcpEnvelope(structured, { + isError: false, + content, + structuredContent: structured, + }); + + expect(envelope.data).toBe(structured); + expect(envelope.meta.structuredContent).toBe(structured); + }); +}); + +describe("isResponseEnvelope", () => { + it("returns true for local envelope", () => { + const envelope = localEnvelope({ x: 1 }, "op.test"); + expect(isResponseEnvelope(envelope)).toBe(true); + }); + + it("returns true for http envelope", () => { + const envelope = httpEnvelope("ok", { + statusCode: 200, + headers: {}, + contentType: "text/plain", + }); + expect(isResponseEnvelope(envelope)).toBe(true); + }); + + it("returns true for mcp envelope", () => { + const envelope = mcpEnvelope("result", { + isError: false, + content: [], + }); + expect(isResponseEnvelope(envelope)).toBe(true); + }); + + it("returns false for null", () => { + expect(isResponseEnvelope(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isResponseEnvelope(undefined)).toBe(false); + }); + + it("returns false for empty object", () => { + expect(isResponseEnvelope({})).toBe(false); + }); + + it("returns false for object with only data", () => { + expect(isResponseEnvelope({ data: "hello" })).toBe(false); + }); + + it("returns false for object with only meta", () => { + expect(isResponseEnvelope({ meta: { source: "local" } })).toBe(false); + }); + + it("returns false for object with meta.source not in known set", () => { + expect(isResponseEnvelope({ data: "hello", meta: { source: "websocket" } })).toBe(false); + }); + + it("returns false for non-object primitives", () => { + expect(isResponseEnvelope(42)).toBe(false); + expect(isResponseEnvelope("string")).toBe(false); + expect(isResponseEnvelope(true)).toBe(false); + }); + + it("returns false when meta is null", () => { + expect(isResponseEnvelope({ data: "hello", meta: null })).toBe(false); + }); + + it("returns true for envelope with undefined data (void handler)", () => { + const envelope = localEnvelope(undefined, "op.void"); + expect(isResponseEnvelope(envelope)).toBe(true); + }); + + it("returns false for array", () => { + expect(isResponseEnvelope([1, 2, 3])).toBe(false); + }); + + it("returns false for object with numeric meta", () => { + expect(isResponseEnvelope({ data: "hello", meta: 42 })).toBe(false); + }); +}); + +describe("unwrap", () => { + it("returns envelope.data for local envelope", () => { + const envelope = localEnvelope({ result: 42 }, "op.compute"); + expect(unwrap(envelope)).toEqual({ result: 42 }); + }); + + it("returns envelope.data for http envelope", () => { + const envelope = httpEnvelope("response body", { + statusCode: 200, + headers: { "content-type": "text/plain" }, + contentType: "text/plain", + }); + expect(unwrap(envelope)).toBe("response body"); + }); + + it("returns envelope.data for mcp envelope", () => { + const data = { items: [1, 2, 3] }; + const envelope = mcpEnvelope(data, { + isError: false, + content: [], + }); + expect(unwrap(envelope)).toBe(data); + }); + + it("returns undefined for void envelope data", () => { + const envelope = localEnvelope(undefined, "op.void"); + expect(unwrap(envelope)).toBeUndefined(); + }); + + it("returns null for null envelope data", () => { + const envelope = localEnvelope(null, "op.null"); + expect(unwrap(envelope)).toBeNull(); + }); +}); + +describe("RESPONSE_SOURCES", () => { + it("contains all known source types", () => { + expect(RESPONSE_SOURCES).toEqual(["local", "http", "mcp"]); + }); +}); + +describe("TypeBox schemas", () => { + describe("ResponseEnvelopeSchema", () => { + it("validates correct envelope shape", () => { + const envelope = localEnvelope({ value: 1 }, "op.test"); + expect(Value.Check(ResponseEnvelopeSchema, envelope)).toBe(true); + }); + + it("validates http envelope shape", () => { + const envelope = httpEnvelope({ ok: true }, { + statusCode: 200, + headers: { "content-type": "application/json" }, + contentType: "application/json", + }); + expect(Value.Check(ResponseEnvelopeSchema, envelope)).toBe(true); + }); + + it("validates mcp envelope shape", () => { + const envelope = mcpEnvelope("result", { + isError: false, + content: [{ type: "text", text: "result" }], + }); + expect(Value.Check(ResponseEnvelopeSchema, envelope)).toBe(true); + }); + + it("rejects object without data", () => { + const value = { meta: { source: "local", operationId: "op", timestamp: Date.now() } }; + expect(Value.Check(ResponseEnvelopeSchema, value)).toBe(false); + }); + + it("rejects object without meta", () => { + const value = { data: "hello" }; + expect(Value.Check(ResponseEnvelopeSchema, value)).toBe(false); + }); + + it("rejects object with invalid meta source", () => { + const value = { data: "hello", meta: { source: "grpc", operationId: "op", timestamp: 1 } }; + expect(Value.Check(ResponseEnvelopeSchema, value)).toBe(false); + }); + + it("rejects object with missing meta fields", () => { + const value = { data: "hello", meta: { source: "local" } }; + expect(Value.Check(ResponseEnvelopeSchema, value)).toBe(false); + }); + }); + + describe("ResponseMetaSchema", () => { + it("validates LocalResponseMeta", () => { + const meta: LocalResponseMeta = { source: "local", operationId: "op.test", timestamp: Date.now() }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates HTTPResponseMeta", () => { + const meta: HTTPResponseMeta = { + source: "http", + statusCode: 200, + headers: { "content-type": "application/json" }, + contentType: "application/json", + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates MCPResponseMeta", () => { + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "text", text: "result" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates MCPResponseMeta with optional fields", () => { + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [], + structuredContent: { key: "value" }, + _meta: { traceId: "abc" }, + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("rejects object with unknown source", () => { + const value = { source: "websocket", timestamp: 1 }; + expect(Value.Check(ResponseMetaSchema, value)).toBe(false); + }); + + it("rejects LocalResponseMeta missing operationId", () => { + const value = { source: "local", timestamp: Date.now() }; + expect(Value.Check(ResponseMetaSchema, value)).toBe(false); + }); + + it("rejects HTTPResponseMeta missing statusCode", () => { + const value = { source: "http", headers: {}, contentType: "text/plain" }; + expect(Value.Check(ResponseMetaSchema, value)).toBe(false); + }); + + it("rejects MCPResponseMeta missing content", () => { + const value = { source: "mcp", isError: false }; + expect(Value.Check(ResponseMetaSchema, value)).toBe(false); + }); + + it("rejects MCPResponseMeta with isError as string", () => { + const value = { source: "mcp", isError: "yes", content: [] }; + expect(Value.Check(ResponseMetaSchema, value)).toBe(false); + }); + }); + + describe("MCPContentBlockSchema (via MCPResponseMeta validation)", () => { + it("validates text content block", () => { + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "text", text: "hello world" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates text content block with annotations", () => { + const annotations: MCPAnnotations = { audience: ["user"], priority: 1 }; + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "text", text: "hello", annotations }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates image content block", () => { + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "image", data: "base64data", mimeType: "image/png" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates audio content block", () => { + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "audio", data: "base64audio", mimeType: "audio/wav" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates resource content block", () => { + const resource: MCPResourceContent = { uri: "file:///test.txt", text: "content" }; + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "resource", resource }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates resource_link content block", () => { + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "resource_link", uri: "file:///doc.pdf", name: "document.pdf" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("validates resource_link with optional fields", () => { + const meta: MCPResponseMeta = { + source: "mcp", + isError: false, + content: [{ type: "resource_link", uri: "file:///doc.pdf", name: "doc", description: "A doc", mimeType: "application/pdf" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(true); + }); + + it("rejects content block with unknown type", () => { + const meta = { + source: "mcp", + isError: false, + content: [{ type: "video", data: "base64video" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(false); + }); + + it("rejects text content block missing text field", () => { + const meta = { + source: "mcp", + isError: false, + content: [{ type: "text" }], + }; + expect(Value.Check(ResponseMetaSchema, meta)).toBe(false); + }); + }); +}); \ No newline at end of file