import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { z } from "zod";
import type { ActionResponseType } from "@superblocksteam/shared";
import { RestApiValidationError } from "../../errors.js";
import { REST_API_RESPONSE_TYPES } from "../base/types.js";
import type { IntegrationConfig } from "../types.js";
import { RestApiIntegrationPluginClientImpl } from "./client.js";
const TEST_CONFIG: IntegrationConfig = {
id: "restapi-test-id",
name: "Test REST API",
pluginId: "restapiintegration",
configuration: {},
};
const XML_RESPONSE = `hi`;
const PDF_BYTES = [0x25, 0x50, 0x44, 0x46];
function workerBufferPayload(data: unknown): unknown {
return { type: "Buffer", data };
}
function createClient(mockResult: unknown) {
const executeQuery = vi.fn().mockResolvedValue(mockResult);
const client = new RestApiIntegrationPluginClientImpl(
TEST_CONFIG,
executeQuery,
);
return { client, executeQuery };
}
function callApiRequest(
client: RestApiIntegrationPluginClientImpl,
...args: unknown[]
): Promise {
return Reflect.apply(client.apiRequest, client, args);
}
describe("RestApiIntegrationPluginClientImpl", () => {
describe("default JSON behavior", () => {
it("sends responseType json when the option is omitted", async () => {
const { client, executeQuery } = createClient({ id: "1" });
await client.apiRequest(
{ method: "GET", path: "/users" },
{ response: z.object({ id: z.string() }) },
);
expect(executeQuery).toHaveBeenCalledWith(
expect.objectContaining({ responseType: "json" }),
undefined,
undefined,
);
});
it("validates the response against the provided schema", async () => {
const { client } = createClient({ id: "1", extra: "stripped" });
const result = await client.apiRequest(
{ method: "GET", path: "/users" },
{ response: z.object({ id: z.string() }) },
);
expect(result).toEqual({ id: "1" });
});
it("throws RestApiValidationError when the response does not match the schema", async () => {
const payload = XML_RESPONSE;
const { client } = createClient(payload);
try {
await client.apiRequest(
{ method: "GET", path: "/users" },
{ response: z.object({ id: z.string() }) },
);
} catch (error) {
expect(error).toBeInstanceOf(RestApiValidationError);
if (!(error instanceof RestApiValidationError)) {
throw error;
}
expect(error.details.data).toBe(payload);
return;
}
throw new Error("Expected JSON response validation to fail");
});
});
describe("responseType passthrough", () => {
it("forwards responseType text to the orchestrator request", async () => {
const { client, executeQuery } = createClient(XML_RESPONSE);
await client.apiRequest({
method: "GET",
path: "/report.xml",
responseType: "text",
});
expect(executeQuery).toHaveBeenCalledWith(
expect.objectContaining({ responseType: "text" }),
undefined,
undefined,
);
});
it("returns a raw XML string with responseType text and no response schema", async () => {
const { client } = createClient(XML_RESPONSE);
const result = await client.apiRequest({
method: "GET",
path: "/report.xml",
responseType: "text",
});
expect(result).toBe(XML_RESPONSE);
});
it("still validates when a response schema is provided alongside responseType", async () => {
const { client, executeQuery } = createClient(XML_RESPONSE);
const result = await client.apiRequest(
{ method: "GET", path: "/report.xml", responseType: "text" },
{ response: z.string() },
);
expect(executeQuery).toHaveBeenCalledWith(
expect.objectContaining({ responseType: "text" }),
undefined,
undefined,
);
expect(result).toBe(XML_RESPONSE);
});
it("returns the raw response without validation when no schema is given", async () => {
const { client } = createClient({ ok: true, rows: [1, 2, 3] });
const result = await client.apiRequest({
method: "GET",
path: "/data",
responseType: "text",
});
expect(result).toEqual({ ok: true, rows: [1, 2, 3] });
});
it("returns an empty string unchanged for responseType text", async () => {
const { client } = createClient("");
const result = await client.apiRequest({
method: "GET",
path: "/empty",
responseType: "text",
});
expect(result).toBe("");
});
it("rejects null and undefined results for every response type", async () => {
for (const badResult of [null, undefined]) {
const { client: textClient } = createClient(badResult);
await expect(
textClient.apiRequest({
method: "GET",
path: "/broken",
responseType: "text",
}),
).rejects.toThrow(RestApiValidationError);
const { client: binaryClient } = createClient(badResult);
await expect(
binaryClient.apiRequest({
method: "GET",
path: "/broken",
responseType: "binary",
}),
).rejects.toThrow(RestApiValidationError);
const { client: jsonClient } = createClient(badResult);
await expect(
jsonClient.apiRequest(
{ method: "GET", path: "/broken" },
{ response: z.object({}) },
),
).rejects.toThrow(RestApiValidationError);
}
});
});
describe("responseType binary", () => {
it("forwards responseType binary and returns decoded bytes", async () => {
const { client, executeQuery } = createClient(
workerBufferPayload(PDF_BYTES),
);
const result = await client.apiRequest({
method: "GET",
path: "/file.pdf",
responseType: "binary",
});
expect(executeQuery).toHaveBeenCalledWith(
expect.objectContaining({ responseType: "binary" }),
undefined,
undefined,
);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.constructor).toBe(Uint8Array);
expect(Array.from(result)).toEqual(PDF_BYTES);
});
it("rejects malformed binary data before applying a response schema", async () => {
const malformed = workerBufferPayload([256]);
const { client, executeQuery } = createClient(malformed);
const workerShapeSchema = z.object({
type: z.literal("Buffer"),
data: z.array(z.number()),
});
await expect(
callApiRequest(
client,
{ method: "GET", path: "/file.pdf", responseType: "binary" },
{ response: workerShapeSchema },
),
).rejects.toThrow(RestApiValidationError);
expect(executeQuery).toHaveBeenCalled();
});
it("accepts response schemas that transform bytes to bytes", async () => {
const { client } = createClient(workerBufferPayload(PDF_BYTES));
const result = await client.apiRequest(
{ method: "GET", path: "/file.pdf", responseType: "binary" },
{
response: z
.instanceof(Uint8Array)
.refine((bytes) => bytes[0] === 0x25)
.transform((bytes) => bytes.slice(1)),
},
);
expect(Array.from(result)).toEqual(PDF_BYTES.slice(1));
});
it("throws RestApiValidationError when the schema rejects normalized bytes", async () => {
const { client } = createClient(workerBufferPayload(PDF_BYTES));
await expect(
client.apiRequest(
{ method: "GET", path: "/file.pdf", responseType: "binary" },
{
response: z
.instanceof(Uint8Array)
.refine((bytes) => bytes.byteLength > 64),
},
),
).rejects.toThrow(RestApiValidationError);
});
it("redacts binary data from response validation errors", async () => {
const bytes = Array.from({ length: 4096 }, () => 0);
const { client } = createClient(workerBufferPayload(bytes));
try {
await client.apiRequest(
{ method: "GET", path: "/file.pdf", responseType: "binary" },
{
response: z
.instanceof(Uint8Array)
.refine((value) => value.byteLength < 1024),
},
);
} catch (error) {
expect(error).toBeInstanceOf(RestApiValidationError);
if (!(error instanceof RestApiValidationError)) {
throw error;
}
expect(error.details.data).toEqual({
dataType: "binary",
redacted: true,
});
return;
}
throw new Error("Expected binary response validation to fail");
});
});
describe("request body validation", () => {
it("throws RestApiValidationError for an invalid body with a body-only schema", async () => {
const { client, executeQuery } = createClient({ ok: true });
const dynamicBody: unknown = JSON.parse('{"amount":"not-a-number"}');
await expect(
client.apiRequest(
{
method: "POST",
path: "/orders",
body: dynamicBody,
responseType: "text",
},
{ body: z.object({ amount: z.number() }) },
),
).rejects.toThrow(RestApiValidationError);
expect(executeQuery).not.toHaveBeenCalled();
});
it("sends a valid body and returns the raw response with a body-only schema", async () => {
const { client, executeQuery } = createClient({ ok: true });
const result = await client.apiRequest(
{
method: "POST",
path: "/orders",
body: { amount: 42 },
responseType: "text",
},
{ body: z.object({ amount: z.number() }) },
);
expect(executeQuery).toHaveBeenCalledWith(
expect.objectContaining({
body: JSON.stringify({ amount: 42 }),
bodyType: "jsonBody",
}),
undefined,
undefined,
);
expect(result).toEqual({ ok: true });
});
});
describe("runtime contract enforcement", () => {
it("rejects a schema-less call in default JSON mode without issuing the request", async () => {
const { client, executeQuery } = createClient({ ok: true });
await expect(
callApiRequest(client, { method: "GET", path: "/users" }),
).rejects.toThrow(RestApiValidationError);
expect(executeQuery).not.toHaveBeenCalled();
});
it("rejects a body-only schema in explicit JSON mode without issuing the request", async () => {
const { client, executeQuery } = createClient({ ok: true });
await expect(
callApiRequest(
client,
{
method: "POST",
path: "/orders",
body: { amount: 42 },
responseType: "json",
},
{ body: z.object({ amount: z.number() }) },
),
).rejects.toThrow(RestApiValidationError);
expect(executeQuery).not.toHaveBeenCalled();
});
it("rejects auto, which can yield unvalidated JSON, and raw, which is streaming-only", async () => {
for (const responseType of ["auto", "raw"]) {
const { client, executeQuery } = createClient({ ok: true });
await expect(
callApiRequest(client, {
method: "GET",
path: "/report",
responseType,
}),
).rejects.toThrow(RestApiValidationError);
expect(executeQuery).not.toHaveBeenCalled();
}
});
});
describe("overload resolution", () => {
it("keeps public response types within the worker contract", () => {
expectTypeOf<(typeof REST_API_RESPONSE_TYPES)[number]>().toMatchTypeOf<
Exclude<`${ActionResponseType}`, "raw">
>();
});
it("keeps typed results for schema callers and unknown for schema-less calls", async () => {
const { client } = createClient({ id: "1" });
const typed: Promise<{ id: string }> = client.apiRequest(
{ method: "GET", path: "/users" },
{ response: z.object({ id: z.string() }) },
);
expect(await typed).toEqual({ id: "1" });
const { client: rawClient } = createClient(XML_RESPONSE);
const raw: Promise = rawClient.apiRequest({
method: "GET",
path: "/report.xml",
responseType: "text",
});
expect(await raw).toBe(XML_RESPONSE);
// @ts-expect-error schema-less apiRequest returns unknown, not a typed shape
const wrong: Promise<{ id: string }> = rawClient.apiRequest({
method: "GET",
path: "/report.xml",
responseType: "text",
});
await wrong;
// @ts-expect-error unvalidated JSON is unrepresentable; schema-less calls must opt into a non-JSON responseType
const unvalidatedJson = rawClient.apiRequest({
method: "GET",
path: "/users",
});
await expect(unvalidatedJson).rejects.toThrow(RestApiValidationError);
});
it("types schema-less binary responses as Uint8Array", async () => {
const { client: binaryClient } = createClient(
workerBufferPayload(PDF_BYTES),
);
const binary: Promise = binaryClient.apiRequest({
method: "GET",
path: "/file.pdf",
responseType: "binary",
});
expectTypeOf(binary).toEqualTypeOf>();
expect(Array.from(await binary)).toEqual(PDF_BYTES);
// @ts-expect-error schema-less binary apiRequest returns Uint8Array
const wrongBinary: Promise = binaryClient.apiRequest({
method: "GET",
path: "/file.pdf",
responseType: "binary",
});
await wrongBinary;
});
it("rejects binary response schemas that transform bytes to another type", () => {
const { client: binaryClient } = createClient(
workerBufferPayload(PDF_BYTES),
);
// @ts-expect-error binary response schemas must output Uint8Array
binaryClient.apiRequest(
{
method: "GET",
path: "/file.pdf",
responseType: "binary",
},
{
response: z
.instanceof(Uint8Array)
.transform((bytes) => bytes.byteLength),
},
);
});
it("rejects response types outside the public SDK contract", async () => {
const { client: rawClient } = createClient(XML_RESPONSE);
const autoCall = rawClient.apiRequest({
method: "GET",
path: "/report.xml",
// @ts-expect-error "auto" is not an exposed responseType
responseType: "auto",
});
await expect(autoCall).rejects.toThrow(RestApiValidationError);
const rawCall = rawClient.apiRequest({
method: "GET",
path: "/report.xml",
// @ts-expect-error "raw" is not an exposed responseType
responseType: "raw",
});
await expect(rawCall).rejects.toThrow(RestApiValidationError);
});
});
});