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); }); }); });