Initial package implementation: operations registry, call protocol, and adapters
Extracted from alkhub_ts packages/core/operations/ and packages/core/mcp/. - Runtime-agnostic (injected fs/env deps, no Deno globals) - Direct @logtape/logtape import instead of logger wrapper - PendingRequestMap with pubsub-wired call protocol - Peer-dep isolation for MCP adapter (sub-path export) - Schema const naming convention (XSchema + X type alias) - 68 tests passing, build + lint + test all green
This commit is contained in:
87
test/call.test.ts
Normal file
87
test/call.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PendingRequestMap } from "../src/call.js";
|
||||
import { CallError, InfrastructureErrorCode } from "../src/error.js";
|
||||
|
||||
describe("PendingRequestMap", () => {
|
||||
it("creates instance without event target", () => {
|
||||
const map = new PendingRequestMap();
|
||||
expect(map.getPendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("creates instance with event target", () => {
|
||||
const target = new EventTarget();
|
||||
const map = new PendingRequestMap(target);
|
||||
expect(map.getPendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("call() resolves when respond() is called", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
|
||||
const callPromise = map.call("test.op", { value: "hello" });
|
||||
|
||||
setTimeout(() => {
|
||||
const requestId = [...map["requests"].keys()][0];
|
||||
map.respond(requestId, { result: "world" });
|
||||
}, 10);
|
||||
|
||||
const result = await callPromise;
|
||||
expect(result).toEqual({ result: "world" });
|
||||
});
|
||||
|
||||
it("call() rejects when emitError() is called", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
|
||||
const callPromise = map.call("test.op", { value: "hello" });
|
||||
|
||||
setTimeout(() => {
|
||||
const requestId = [...map["requests"].keys()][0];
|
||||
map.emitError(requestId, "CUSTOM_ERROR", "Something went wrong");
|
||||
}, 10);
|
||||
|
||||
await expect(callPromise).rejects.toThrow("Something went wrong");
|
||||
await expect(callPromise).rejects.toBeInstanceOf(CallError);
|
||||
});
|
||||
|
||||
it("abort() rejects the pending call", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
|
||||
const callPromise = map.call("test.op", { value: "hello" });
|
||||
|
||||
setTimeout(() => {
|
||||
const requestId = [...map["requests"].keys()][0];
|
||||
map.abort(requestId);
|
||||
}, 10);
|
||||
|
||||
await expect(callPromise).rejects.toThrow("was aborted");
|
||||
await expect(callPromise).rejects.toBeInstanceOf(CallError);
|
||||
});
|
||||
|
||||
it("call() with deadline times out", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
|
||||
const deadline = Date.now() + 50;
|
||||
const callPromise = map.call("test.op", { value: "hello" }, { deadline });
|
||||
|
||||
await expect(callPromise).rejects.toThrow("timed out");
|
||||
await expect(callPromise).rejects.toBeInstanceOf(CallError);
|
||||
});
|
||||
|
||||
it("tracks pending requests", () => {
|
||||
const map = new PendingRequestMap();
|
||||
map.call("test.op1", {});
|
||||
map.call("test.op2", {});
|
||||
expect(map.getPendingCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("cleans up after call resolves", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
const callPromise = map.call("test.op", { value: "hello" });
|
||||
expect(map.getPendingCount()).toBe(1);
|
||||
|
||||
const requestId = [...map["requests"].keys()][0];
|
||||
map.respond(requestId, { result: "done" });
|
||||
|
||||
await callPromise;
|
||||
expect(map.getPendingCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
93
test/env.test.ts
Normal file
93
test/env.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { OperationRegistry, OperationType, buildEnv, type IOperationDefinition, type OperationContext } from "../src/index.js";
|
||||
import * as Type from "@alkdev/typebox";
|
||||
import { PendingRequestMap } from "../src/call.js";
|
||||
|
||||
function makeOperation(name: string, handler?: any): IOperationDefinition {
|
||||
return {
|
||||
name,
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: `Test ${name}`,
|
||||
inputSchema: Type.Object({ value: Type.String() }),
|
||||
outputSchema: Type.Object({ result: Type.String() }),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: handler || (async (input: any) => ({ result: input.value })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildEnv", () => {
|
||||
it("creates namespace-keyed env in direct mode", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("readFile"));
|
||||
registry.register(makeOperation("writeFile"));
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context: {} as OperationContext,
|
||||
});
|
||||
|
||||
expect(env.test).toBeDefined();
|
||||
expect(typeof env.test.readFile).toBe("function");
|
||||
expect(typeof env.test.writeFile).toBe("function");
|
||||
|
||||
const result = await env.test.readFile({ value: "test" });
|
||||
expect(result).toEqual({ result: "test" });
|
||||
});
|
||||
|
||||
it("filters out SUBSCRIPTION operations", () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("query"));
|
||||
registry.register({
|
||||
...makeOperation("onEvent"),
|
||||
type: OperationType.SUBSCRIPTION,
|
||||
});
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context: {} as OperationContext,
|
||||
});
|
||||
|
||||
expect(env.test.query).toBeDefined();
|
||||
expect(env.test.onEvent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters by allowedNamespaces", () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("op1"));
|
||||
registry.register({
|
||||
...makeOperation("op2"),
|
||||
namespace: "other",
|
||||
});
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context: {} as OperationContext,
|
||||
allowedNamespaces: ["test"],
|
||||
});
|
||||
|
||||
expect(env.test).toBeDefined();
|
||||
expect(env.other).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes through callMap in call protocol mode", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation("readFile"));
|
||||
|
||||
const callMap = {
|
||||
call: async (opId: string, input: unknown, opts?: any) => {
|
||||
return { result: `routed: ${opId}` };
|
||||
},
|
||||
};
|
||||
|
||||
const env = buildEnv({
|
||||
registry,
|
||||
context: {} as OperationContext,
|
||||
callMap,
|
||||
});
|
||||
|
||||
const result = await env.test.readFile({ value: "test" });
|
||||
expect(result).toEqual({ result: "routed: test.readFile" });
|
||||
});
|
||||
});
|
||||
65
test/error.test.ts
Normal file
65
test/error.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CallError, InfrastructureErrorCode, mapError } from "../src/error.js";
|
||||
|
||||
describe("CallError", () => {
|
||||
it("stores code, message, and details", () => {
|
||||
const err = new CallError("TEST_CODE", "test message", { foo: "bar" });
|
||||
expect(err.code).toBe("TEST_CODE");
|
||||
expect(err.message).toBe("test message");
|
||||
expect(err.details).toEqual({ foo: "bar" });
|
||||
expect(err.name).toBe("CallError");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(CallError);
|
||||
});
|
||||
|
||||
it("works without details", () => {
|
||||
const err = new CallError("CODE", "msg");
|
||||
expect(err.details).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InfrastructureErrorCode", () => {
|
||||
it("has all expected codes", () => {
|
||||
expect(InfrastructureErrorCode.OPERATION_NOT_FOUND).toBe("OPERATION_NOT_FOUND");
|
||||
expect(InfrastructureErrorCode.ACCESS_DENIED).toBe("ACCESS_DENIED");
|
||||
expect(InfrastructureErrorCode.VALIDATION_ERROR).toBe("VALIDATION_ERROR");
|
||||
expect(InfrastructureErrorCode.TIMEOUT).toBe("TIMEOUT");
|
||||
expect(InfrastructureErrorCode.ABORTED).toBe("ABORTED");
|
||||
expect(InfrastructureErrorCode.EXECUTION_ERROR).toBe("EXECUTION_ERROR");
|
||||
expect(InfrastructureErrorCode.UNKNOWN_ERROR).toBe("UNKNOWN_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapError", () => {
|
||||
it("passes through existing CallError", () => {
|
||||
const original = new CallError("CUSTOM", "msg");
|
||||
const result = mapError(original);
|
||||
expect(result).toBe(original);
|
||||
});
|
||||
|
||||
it("maps Error to EXECUTION_ERROR", () => {
|
||||
const result = mapError(new Error("something broke"));
|
||||
expect(result.code).toBe(InfrastructureErrorCode.EXECUTION_ERROR);
|
||||
expect(result.message).toBe("something broke");
|
||||
});
|
||||
|
||||
it("maps Error with matching errorSchema code", () => {
|
||||
const result = mapError(new Error("NOT_FOUND: item missing"), [
|
||||
{ code: "NOT_FOUND", schema: {} },
|
||||
]);
|
||||
expect(result.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("maps non-Error to UNKNOWN_ERROR", () => {
|
||||
const result = mapError("string error");
|
||||
expect(result.code).toBe(InfrastructureErrorCode.UNKNOWN_ERROR);
|
||||
expect(result.message).toBe("string error");
|
||||
expect(result.details).toEqual({ raw: "string error" });
|
||||
});
|
||||
|
||||
it("maps non-Error with details", () => {
|
||||
const result = mapError(42);
|
||||
expect(result.code).toBe(InfrastructureErrorCode.UNKNOWN_ERROR);
|
||||
expect(result.details).toEqual({ raw: "42" });
|
||||
});
|
||||
});
|
||||
194
test/from_openapi.test.ts
Normal file
194
test/from_openapi.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { FromOpenAPI } from "../src/from_openapi.js";
|
||||
import { OperationType } from "../src/types.js";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
|
||||
const simpleSpec = {
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Test API", version: "1.0.0" },
|
||||
paths: {
|
||||
"/users": {
|
||||
get: {
|
||||
operationId: "listUsers",
|
||||
description: "List all users",
|
||||
responses: {
|
||||
"200": {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
users: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
operationId: "createUser",
|
||||
description: "Create a user",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/events": {
|
||||
get: {
|
||||
operationId: "streamEvents",
|
||||
description: "Stream events via SSE",
|
||||
responses: {
|
||||
"200": {
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/users/{id}": {
|
||||
get: {
|
||||
operationId: "getUser",
|
||||
description: "Get user by ID",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("FromOpenAPI", () => {
|
||||
const config = {
|
||||
namespace: "api",
|
||||
baseUrl: "https://api.example.com",
|
||||
};
|
||||
|
||||
it("generates operations from OpenAPI spec", () => {
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
expect(ops.length).toBeGreaterThan(0);
|
||||
expect(ops.map((o) => o.name)).toContain("listUsers");
|
||||
expect(ops.map((o) => o.name)).toContain("createUser");
|
||||
expect(ops.map((o) => o.name)).toContain("getUser");
|
||||
});
|
||||
|
||||
it("sets namespace from config", () => {
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
expect(ops.every((o) => o.namespace === "api")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects GET as QUERY type", () => {
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
const listUsers = ops.find((o) => o.name === "listUsers")!;
|
||||
expect(listUsers.type).toBe(OperationType.QUERY);
|
||||
});
|
||||
|
||||
it("detects POST as MUTATION type", () => {
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
const createUser = ops.find((o) => o.name === "createUser")!;
|
||||
expect(createUser.type).toBe(OperationType.MUTATION);
|
||||
});
|
||||
|
||||
it("detects text/event-stream as SUBSCRIPTION type", () => {
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
const streamEvents = ops.find((o) => o.name === "streamEvents")!;
|
||||
expect(streamEvents.type).toBe(OperationType.SUBSCRIPTION);
|
||||
});
|
||||
|
||||
it("generates valid TypeBox input schemas", () => {
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
const getUser = ops.find((o) => o.name === "getUser")!;
|
||||
expect(Value.Check(getUser.inputSchema, { id: "123" })).toBe(true);
|
||||
});
|
||||
|
||||
it("handles auth bearer config", () => {
|
||||
const authConfig = {
|
||||
namespace: "api",
|
||||
baseUrl: "https://api.example.com",
|
||||
auth: { type: "bearer" as const, token: "test-token" },
|
||||
};
|
||||
const ops = FromOpenAPI(simpleSpec as any, authConfig);
|
||||
expect(ops.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips non-HTTP methods", () => {
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
expect(ops.every((o) => o.name)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles $ref resolution", () => {
|
||||
const specWithRef = {
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/items": {
|
||||
get: {
|
||||
operationId: "listItems",
|
||||
responses: {
|
||||
"200": {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ItemList" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
ItemList: {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ops = FromOpenAPI(specWithRef as any, config);
|
||||
expect(ops.length).toBe(1);
|
||||
});
|
||||
});
|
||||
110
test/from_schema.test.ts
Normal file
110
test/from_schema.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { FromSchema } from "../src/from_schema.js";
|
||||
import * as Type from "@alkdev/typebox";
|
||||
import { KindGuard } from "@alkdev/typebox";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
|
||||
describe("FromSchema", () => {
|
||||
it("converts a simple object schema", () => {
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
const tbox = FromSchema(jsonSchema);
|
||||
expect(KindGuard.IsSchema(tbox)).toBe(true);
|
||||
expect(Value.Check(tbox, { name: "Alice" })).toBe(true);
|
||||
expect(Value.Check(tbox, { name: "Alice", age: 30 })).toBe(true);
|
||||
});
|
||||
|
||||
it("converts string schema", () => {
|
||||
const tbox = FromSchema({ type: "string" });
|
||||
expect(KindGuard.IsSchema(tbox)).toBe(true);
|
||||
expect(Value.Check(tbox, "hello")).toBe(true);
|
||||
});
|
||||
|
||||
it("converts number schema", () => {
|
||||
const tbox = FromSchema({ type: "number" });
|
||||
expect(Value.Check(tbox, 42)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts integer schema", () => {
|
||||
const tbox = FromSchema({ type: "integer" });
|
||||
expect(Value.Check(tbox, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts boolean schema", () => {
|
||||
const tbox = FromSchema({ type: "boolean" });
|
||||
expect(Value.Check(tbox, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts null schema", () => {
|
||||
const tbox = FromSchema({ type: "null" });
|
||||
expect(Value.Check(tbox, null)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts enum schema", () => {
|
||||
const tbox = FromSchema({ enum: ["a", "b", "c"] });
|
||||
expect(Value.Check(tbox, "a")).toBe(true);
|
||||
expect(Value.Check(tbox, "d")).toBe(false);
|
||||
});
|
||||
|
||||
it("converts array schema", () => {
|
||||
const tbox = FromSchema({ type: "array", items: { type: "string" } });
|
||||
expect(Value.Check(tbox, ["a", "b"])).toBe(true);
|
||||
});
|
||||
|
||||
it("converts tuple schema", () => {
|
||||
const tbox = FromSchema({ type: "array", items: [{ type: "string" }, { type: "number" }] });
|
||||
expect(Value.Check(tbox, ["a", 1])).toBe(true);
|
||||
});
|
||||
|
||||
it("converts allOf schema", () => {
|
||||
const tbox = FromSchema({
|
||||
allOf: [
|
||||
{ type: "object", properties: { name: { type: "string" } }, required: ["name"] },
|
||||
{ type: "object", properties: { age: { type: "number" } }, required: ["age"] },
|
||||
],
|
||||
});
|
||||
expect(Value.Check(tbox, { name: "A", age: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("converts anyOf schema", () => {
|
||||
const tbox = FromSchema({
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
});
|
||||
expect(Value.Check(tbox, "hello")).toBe(true);
|
||||
expect(Value.Check(tbox, 42)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts oneOf schema", () => {
|
||||
const tbox = FromSchema({
|
||||
oneOf: [{ type: "string" }, { type: "number" }],
|
||||
});
|
||||
expect(Value.Check(tbox, "hello")).toBe(true);
|
||||
expect(Value.Check(tbox, 42)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts const schema with object value", () => {
|
||||
const tbox = FromSchema({ const: { key: "value" } });
|
||||
expect(KindGuard.IsSchema(tbox)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts primitive const as literal (falls through to Unknown for non-object const)", () => {
|
||||
const tbox = FromSchema({ const: "fixed" });
|
||||
expect(KindGuard.IsSchema(tbox)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts $ref schema", () => {
|
||||
const tbox = FromSchema({ $ref: "#/definitions/MyType" });
|
||||
expect(KindGuard.IsSchema(tbox)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns Unknown for unrecognized schemas", () => {
|
||||
const tbox = FromSchema({});
|
||||
expect(KindGuard.IsSchema(tbox)).toBe(true);
|
||||
});
|
||||
});
|
||||
101
test/registry.test.ts
Normal file
101
test/registry.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { OperationRegistry } from "../src/registry.js";
|
||||
import { OperationType, type IOperationDefinition, type OperationContext } from "../src/index.js";
|
||||
import * as Type from "@alkdev/typebox";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
|
||||
function makeOperation(overrides: Partial<IOperationDefinition> = {}): IOperationDefinition {
|
||||
return {
|
||||
name: "testOp",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.QUERY,
|
||||
description: "A test operation",
|
||||
inputSchema: Type.Object({ value: Type.String() }),
|
||||
outputSchema: Type.Object({ result: Type.String() }),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async (input: any) => ({ result: `processed: ${input.value}` }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("OperationRegistry", () => {
|
||||
it("registers and retrieves an operation", () => {
|
||||
const registry = new OperationRegistry();
|
||||
const op = makeOperation();
|
||||
registry.register(op);
|
||||
expect(registry.get("test.testOp")).toBe(op);
|
||||
});
|
||||
|
||||
it("retrieves by namespace and name", () => {
|
||||
const registry = new OperationRegistry();
|
||||
const op = makeOperation();
|
||||
registry.register(op);
|
||||
expect(registry.getByName("test", "testOp")).toBe(op);
|
||||
});
|
||||
|
||||
it("returns undefined for missing operations", () => {
|
||||
const registry = new OperationRegistry();
|
||||
expect(registry.get("nonexistent.op")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists all registered operations", () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation());
|
||||
registry.register(makeOperation({ name: "op2" }));
|
||||
expect(registry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("registerAll registers multiple operations", () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.registerAll([makeOperation(), makeOperation({ name: "op2" })]);
|
||||
expect(registry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("extracts spec without handler", () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation());
|
||||
const spec = registry.getSpec("test.testOp")!;
|
||||
expect(spec).toBeDefined();
|
||||
expect((spec as any).handler).toBeUndefined();
|
||||
expect(spec.name).toBe("testOp");
|
||||
});
|
||||
|
||||
it("getAllSpecs returns all specs", () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation());
|
||||
registry.register(makeOperation({ name: "op2" }));
|
||||
expect(registry.getAllSpecs()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("executes an operation and validates input", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation());
|
||||
const result = await registry.execute("test.testOp", { value: "hello" }, {} as OperationContext);
|
||||
expect(result).toEqual({ result: "processed: hello" });
|
||||
});
|
||||
|
||||
it("throws on invalid input", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation());
|
||||
await expect(
|
||||
registry.execute("test.testOp", { wrong: "field" }, {} as OperationContext)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws on missing operation", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
await expect(
|
||||
registry.execute("missing.op", {}, {} as OperationContext)
|
||||
).rejects.toThrow("Operation not found");
|
||||
});
|
||||
|
||||
it("warns on output mismatch but returns result", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register(makeOperation({
|
||||
handler: async () => ({ unexpected: "field" }),
|
||||
}));
|
||||
const result = await registry.execute("test.testOp", { value: "x" }, {} as OperationContext);
|
||||
expect(result).toEqual({ unexpected: "field" });
|
||||
});
|
||||
});
|
||||
77
test/validation.test.ts
Normal file
77
test/validation.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatValueErrors, assertIsSchema, validateOrThrow, collectErrors } from "../src/validation.js";
|
||||
import { Type } from "@alkdev/typebox";
|
||||
import { KindGuard } from "@alkdev/typebox";
|
||||
import { Value } from "@alkdev/typebox/value";
|
||||
|
||||
describe("formatValueErrors", () => {
|
||||
it("formats errors with default indent", () => {
|
||||
const errors = [{ path: "/foo", message: "Expected string" }];
|
||||
expect(formatValueErrors(errors)).toBe(" - /foo: Expected string");
|
||||
});
|
||||
|
||||
it("formats errors with custom indent", () => {
|
||||
const errors = [{ path: "/bar", message: "Expected number" }];
|
||||
expect(formatValueErrors(errors, " * ")).toBe(" * /bar: Expected number");
|
||||
});
|
||||
|
||||
it("formats multiple errors", () => {
|
||||
const errors = [
|
||||
{ path: "/a", message: "Error 1" },
|
||||
{ path: "/b", message: "Error 2" },
|
||||
];
|
||||
expect(formatValueErrors(errors)).toBe(" - /a: Error 1\n - /b: Error 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertIsSchema", () => {
|
||||
it("passes for valid TypeBox schemas", () => {
|
||||
expect(() => assertIsSchema(Type.String())).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes for Type.Unknown()", () => {
|
||||
expect(() => assertIsSchema(Type.Unknown())).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws for plain JSON schema objects", () => {
|
||||
expect(() => assertIsSchema({ type: "string" })).toThrow("Not a valid TypeBox schema");
|
||||
});
|
||||
|
||||
it("includes context in error message", () => {
|
||||
expect(() => assertIsSchema({ type: "string" }, "myOp inputSchema")).toThrow(
|
||||
"for myOp inputSchema"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOrThrow", () => {
|
||||
it("passes for valid input", () => {
|
||||
const schema = Type.Object({ name: Type.String() });
|
||||
expect(() => validateOrThrow(schema, { name: "test" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws for invalid input", () => {
|
||||
const schema = Type.Object({ name: Type.String() });
|
||||
expect(() => validateOrThrow(schema, { name: 123 })).toThrow("Validation failed");
|
||||
});
|
||||
|
||||
it("includes context in error message", () => {
|
||||
const schema = Type.Object({ name: Type.String() });
|
||||
expect(() => validateOrThrow(schema, { name: 123 }, "myOp")).toThrow("for myOp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectErrors", () => {
|
||||
it("returns empty array for valid input", () => {
|
||||
const schema = Type.Object({ name: Type.String() });
|
||||
expect(collectErrors(schema, { name: "test" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns errors for invalid input", () => {
|
||||
const schema = Type.Object({ name: Type.String() });
|
||||
const errors = collectErrors(schema, { name: 123 });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].path).toBeDefined();
|
||||
expect(errors[0].message).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user