fix: add call.completed signaling (M-04) and forward SSE requestBody (L-04)
This commit is contained in:
@@ -1162,7 +1162,6 @@ describe("CallHandler SUBSCRIPTION dispatch", () => {
|
||||
const consumePromise = (async () => {
|
||||
try {
|
||||
for await (const _ of subscribeIter) {
|
||||
// should not reach
|
||||
}
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
@@ -1184,4 +1183,149 @@ describe("CallHandler SUBSCRIPTION dispatch", () => {
|
||||
expect(caughtError).toBeInstanceOf(CallError);
|
||||
expect((caughtError as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("call.completed signaling", () => {
|
||||
it("complete() closes subscription iterator", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
let completedRequestId: string | undefined;
|
||||
|
||||
const completedIter = map["pubsub"].subscribe("call.completed", "");
|
||||
const completedPromise = (async () => {
|
||||
for await (const envelope of completedIter) {
|
||||
completedRequestId = envelope.payload.requestId;
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
const subscribeIter = map.subscribe("test.stream", {});
|
||||
let iterationCompleted = false;
|
||||
|
||||
const consumePromise = (async () => {
|
||||
for await (const _ of subscribeIter) {
|
||||
}
|
||||
iterationCompleted = true;
|
||||
})();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const requestId = [...map["entries"].keys()][0];
|
||||
|
||||
map.respond(requestId, localEnvelope("event1", "test.stream"));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
map.complete(requestId);
|
||||
|
||||
await consumePromise;
|
||||
await completedPromise;
|
||||
|
||||
expect(iterationCompleted).toBe(true);
|
||||
expect(completedRequestId).toBe(requestId);
|
||||
expect(map.getPendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("buildCallHandler publishes call.completed when subscription generator finishes", async () => {
|
||||
const registry = new OperationRegistry();
|
||||
registry.register({
|
||||
name: "finite",
|
||||
namespace: "test",
|
||||
version: "1.0.0",
|
||||
type: OperationType.SUBSCRIPTION,
|
||||
description: "finite stream",
|
||||
inputSchema: Type.Object({}),
|
||||
outputSchema: Type.Unknown(),
|
||||
accessControl: { requiredScopes: [] },
|
||||
handler: async function* (_input: unknown, _context: unknown) {
|
||||
yield "a";
|
||||
yield "b";
|
||||
},
|
||||
});
|
||||
|
||||
const callMap = new PendingRequestMap();
|
||||
const handler = buildCallHandler({ registry, callMap });
|
||||
|
||||
let completedRequestId: string | undefined;
|
||||
const completedIter = callMap["pubsub"].subscribe("call.completed", "");
|
||||
const completedPromise = (async () => {
|
||||
for await (const envelope of completedIter) {
|
||||
completedRequestId = envelope.payload.requestId;
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
const subscribeIter = callMap.subscribe("test.finite", {});
|
||||
const results: ResponseEnvelope[] = [];
|
||||
const consumePromise = (async () => {
|
||||
for await (const envelope of subscribeIter) {
|
||||
results.push(envelope);
|
||||
}
|
||||
})();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const requestId = [...callMap["entries"].keys()][0];
|
||||
|
||||
await handler({
|
||||
requestId,
|
||||
operationId: "test.finite",
|
||||
input: {},
|
||||
});
|
||||
|
||||
await consumePromise;
|
||||
await completedPromise;
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].data).toBe("a");
|
||||
expect(results[1].data).toBe("b");
|
||||
expect(completedRequestId).toBe(requestId);
|
||||
expect(callMap.getPendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("complete() on a call entry rejects the pending promise", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
|
||||
let completedRequestId: string | undefined;
|
||||
const completedIter = map["pubsub"].subscribe("call.completed", "");
|
||||
const completedPromise = (async () => {
|
||||
for await (const envelope of completedIter) {
|
||||
completedRequestId = envelope.payload.requestId;
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
const callPromise = map.call("test.op", {});
|
||||
const requestId = [...map["entries"].keys()][0];
|
||||
|
||||
map.complete(requestId);
|
||||
|
||||
await completedPromise;
|
||||
expect(completedRequestId).toBe(requestId);
|
||||
|
||||
try {
|
||||
await callPromise;
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CallError);
|
||||
expect((error as CallError).code).toBe(InfrastructureErrorCode.ABORTED);
|
||||
expect((error as CallError).message).toContain("completed without response");
|
||||
}
|
||||
|
||||
expect(map.getPendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("complete() with invalid requestId still publishes event", async () => {
|
||||
const map = new PendingRequestMap();
|
||||
|
||||
let completedReceived = false;
|
||||
const completedIter = map["pubsub"].subscribe("call.completed", "");
|
||||
const completedPromise = (async () => {
|
||||
for await (const envelope of completedIter) {
|
||||
completedReceived = true;
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
map.complete("nonexistent-id");
|
||||
|
||||
await completedPromise;
|
||||
expect(completedReceived).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -579,7 +579,6 @@ describe("FromOpenAPI SUBSCRIPTION handler", () => {
|
||||
|
||||
try {
|
||||
for await (const _ of handler({}, {} as any)) {
|
||||
// should not reach
|
||||
}
|
||||
expect.fail("Expected CallError");
|
||||
} catch (error) {
|
||||
@@ -588,4 +587,112 @@ describe("FromOpenAPI SUBSCRIPTION handler", () => {
|
||||
expect((error as CallError).message).toContain("HTTP 500");
|
||||
}
|
||||
});
|
||||
|
||||
it("SSE handler forwards body in request when provided", async () => {
|
||||
const sseStream = "data: {\"ok\":true}\n\n";
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const reader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: encoder.encode(sseStream) })
|
||||
.mockResolvedValueOnce({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
body: { getReader: () => reader },
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const specWithBody = {
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/events": {
|
||||
post: {
|
||||
operationId: "postEvents",
|
||||
description: "Post and stream events",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { filter: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ops = FromOpenAPI(specWithBody as any, config);
|
||||
const postEvents = ops.find((o) => o.name === "postEvents")!;
|
||||
const handler = postEvents.handler as SubscriptionHandler<unknown, unknown, unknown>;
|
||||
|
||||
const results: unknown[] = [];
|
||||
for await (const value of handler({ body: { filter: "important" } }, {} as any)) {
|
||||
results.push(value);
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const fetchArgs = fetchMock.mock.calls[0];
|
||||
expect(fetchArgs[1].method).toBe("POST");
|
||||
expect(fetchArgs[1].body).toBe(JSON.stringify({ filter: "important" }));
|
||||
expect(fetchArgs[1].headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
if (isResponseEnvelope(results[0])) {
|
||||
expect(results[0].data).toEqual({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("SSE handler does not send body when input has no body key", async () => {
|
||||
const sseStream = "data: {\"event\":\"ping\"}\n\n";
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const reader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: encoder.encode(sseStream) })
|
||||
.mockResolvedValueOnce({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||
body: { getReader: () => reader },
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||
const streamEvents = ops.find((o) => o.name === "streamEvents")!;
|
||||
const handler = streamEvents.handler as SubscriptionHandler<unknown, unknown, unknown>;
|
||||
|
||||
const results: unknown[] = [];
|
||||
for await (const value of handler({}, {} as any)) {
|
||||
results.push(value);
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const fetchArgs = fetchMock.mock.calls[0];
|
||||
expect(fetchArgs[1].method).toBe("GET");
|
||||
expect(fetchArgs[1].body).toBeUndefined();
|
||||
expect(fetchArgs[1].headers["Content-Type"]).toBeUndefined();
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user