feat: add SDK foundation layer
Implement config resolution, typed errors, HTTP and WebSocket transport helpers, and timeout handling for the SDK foundation. Add unit and local integration tests covering the SDK foundation behaviour and align package exports with the tsup output.
This commit is contained in:
183
tests/integration/foundation.integration.test.ts
Normal file
183
tests/integration/foundation.integration.test.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { HttpClient } from "../../src/_shared/http.js";
|
||||
import { WsConnection } from "../../src/_shared/websocket.js";
|
||||
import { ConflictError, TimeoutError } from "../../src/exceptions.js";
|
||||
|
||||
interface CapturedRequest {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
body: string;
|
||||
}
|
||||
|
||||
function readRequestBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
request.on("end", () => resolve(body));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server: ReturnType<typeof createServer>): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeHttpServer(
|
||||
server: ReturnType<typeof createServer>,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeWebSocketServer(server: WebSocketServer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("foundation integration", () => {
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanup.splice(0).map((close) => close()));
|
||||
});
|
||||
|
||||
it("sends JSON requests through a real HTTP server", async () => {
|
||||
let captured: CapturedRequest | undefined;
|
||||
const server = createServer(async (request, response: ServerResponse) => {
|
||||
captured = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await readRequestBody(request),
|
||||
};
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
const port = await listen(server);
|
||||
cleanup.push(() => closeHttpServer(server));
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
apiKey: "api-key",
|
||||
token: "jwt-token",
|
||||
hostToken: "host-token",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post(
|
||||
"/v1/foundation",
|
||||
{ hello: "world" },
|
||||
{ params: { page: 1 } },
|
||||
),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(captured).toMatchObject({
|
||||
method: "POST",
|
||||
url: "/v1/foundation?page=1",
|
||||
body: JSON.stringify({ hello: "world" }),
|
||||
});
|
||||
expect(captured?.headers["content-type"]).toBe("application/json");
|
||||
expect(captured?.headers.accept).toBe("application/json");
|
||||
expect(captured?.headers.authorization).toBe("Bearer jwt-token");
|
||||
expect(captured?.headers["x-api-key"]).toBe("api-key");
|
||||
expect(captured?.headers["x-host-token"]).toBe("host-token");
|
||||
});
|
||||
|
||||
it("maps real HTTP error responses to SDK errors", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(409, { "Content-Type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
error: { code: "capsule_busy", message: "Capsule is busy" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
const port = await listen(server);
|
||||
cleanup.push(() => closeHttpServer(server));
|
||||
|
||||
const client = new HttpClient({ baseUrl: `http://127.0.0.1:${port}` });
|
||||
|
||||
await expect(client.get("/v1/error")).rejects.toMatchObject({
|
||||
code: "capsule_busy",
|
||||
message: "Capsule is busy",
|
||||
statusCode: 409,
|
||||
});
|
||||
await expect(client.get("/v1/error")).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
|
||||
it("aborts real HTTP requests using timeoutMs", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
setTimeout(() => {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
}, 200);
|
||||
});
|
||||
const port = await listen(server);
|
||||
cleanup.push(() => closeHttpServer(server));
|
||||
|
||||
const client = new HttpClient({ baseUrl: `http://127.0.0.1:${port}` });
|
||||
|
||||
await expect(
|
||||
client.get("/v1/slow", { timeoutMs: 10 }),
|
||||
).rejects.toBeInstanceOf(TimeoutError);
|
||||
});
|
||||
|
||||
it("exchanges JSON messages through a real WebSocket server", async () => {
|
||||
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
cleanup.push(() => closeWebSocketServer(server));
|
||||
|
||||
const receivedByServer = new Promise<unknown>((resolve) => {
|
||||
server.on("connection", (socket, request) => {
|
||||
expect(request.headers["x-api-key"]).toBe("api-key");
|
||||
expect(request.headers["x-host-token"]).toBe("host-token");
|
||||
socket.on("message", (raw) => resolve(JSON.parse(raw.toString())));
|
||||
socket.send(JSON.stringify({ type: "ready" }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
|
||||
const messages: unknown[] = [];
|
||||
const connection = await WsConnection.connect({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
path: "/v1/ws",
|
||||
apiKey: "api-key",
|
||||
hostToken: "host-token",
|
||||
onMessage: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
connection.send({ type: "start" });
|
||||
|
||||
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
||||
expect(messages).toEqual([{ type: "ready" }]);
|
||||
expect(connection.isClosed).toBe(false);
|
||||
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user