217 lines
6.7 KiB
JavaScript
217 lines
6.7 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// --- Helpers ---
|
|
|
|
function createMockClient(responseBody) {
|
|
const mockCreate = vi.fn(async () => ({
|
|
output_text: responseBody?.output_text ?? "OK response text",
|
|
model: responseBody?.model ?? "gpt-5.1",
|
|
usage: responseBody?.usage ?? { inputTokens: 10, outputTokens: 20 },
|
|
}));
|
|
|
|
return { responses: { create: mockCreate } };
|
|
}
|
|
|
|
function makeAuthError() {
|
|
const err = new Error("Authentication failed. Provide a valid OPENAI_API_KEY.");
|
|
err.status = 401;
|
|
return err;
|
|
}
|
|
|
|
function makeRateLimitError() {
|
|
const err = new Error("Rate limit exceeded.");
|
|
err.status = 429;
|
|
return err;
|
|
}
|
|
|
|
function makeNetworkError() {
|
|
const err = new Error("fetch failed");
|
|
err.status = undefined;
|
|
return err;
|
|
}
|
|
|
|
function makeServerError() {
|
|
const err = new Error("Service unavailable");
|
|
err.status = 503;
|
|
return err;
|
|
}
|
|
|
|
function makeTimeoutError() {
|
|
const err = new Error("timeout");
|
|
err.code = "ETIMEDOUT";
|
|
return err;
|
|
}
|
|
|
|
// --- Import ---
|
|
|
|
const { sendOpenAIResponse } = await import("../../src/openai/responses.js");
|
|
|
|
describe("sendOpenAIResponse", () => {
|
|
let mockClient;
|
|
let mockCreate;
|
|
|
|
beforeEach(() => {
|
|
mockClient = createMockClient();
|
|
({ responses: { create: mockCreate } } = mockClient);
|
|
});
|
|
|
|
// ===== Success (3) =====
|
|
|
|
it("passes correct OpenAI params to client.responses.create() including max_output_tokens", async () => {
|
|
const input = [{ type: "input_text", text: "Hello" }];
|
|
await sendOpenAIResponse(mockClient, {
|
|
input,
|
|
model: "gpt-5.1",
|
|
temperature: 0.2,
|
|
maxOutputTokens: 2000,
|
|
});
|
|
|
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
const callArgs = mockCreate.mock.calls[0][0];
|
|
expect(callArgs.model).toBe("gpt-5.1");
|
|
expect(callArgs.input).toBe(input);
|
|
expect(callArgs.temperature).toBe(0.2);
|
|
expect(callArgs.max_output_tokens).toBe(2000);
|
|
});
|
|
|
|
it("omits max_output_tokens when maxOutputTokens is not provided", async () => {
|
|
const input = [{ type: "input_text", text: "Hello" }];
|
|
await sendOpenAIResponse(mockClient, {
|
|
input,
|
|
model: "gpt-5.1",
|
|
temperature: 0.2,
|
|
});
|
|
|
|
const callArgs = mockCreate.mock.calls[0][0];
|
|
expect(callArgs).not.toHaveProperty("max_output_tokens");
|
|
});
|
|
|
|
it("returns response.output_text as content", async () => {
|
|
const client = createMockClient({ output_text: "Custom text" });
|
|
const result = await sendOpenAIResponse(client, {
|
|
input: [{ type: "input_text", text: "x" }],
|
|
model: "gpt-5.1",
|
|
});
|
|
expect(result.content).toBe("Custom text");
|
|
});
|
|
|
|
// ===== Validation (4) =====
|
|
|
|
it("throws ValidationError when client is missing", async () => {
|
|
await expect(
|
|
sendOpenAIResponse(null, { input: ["x"], model: "m" })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
it("throws ValidationError when client.responses.create is missing", async () => {
|
|
const badClient = { responses: {} };
|
|
await expect(
|
|
sendOpenAIResponse(badClient, { input: ["x"], model: "m" })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
it("throws ValidationError when params.input is missing", async () => {
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { model: "m" })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
it("throws ValidationError when params.model is missing", async () => {
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"] })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
// ===== Error mapping (3) =====
|
|
|
|
it("maps 401 to OpenAIAuthError", async () => {
|
|
mockCreate.mockRejectedValueOnce(makeAuthError());
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m" })
|
|
).rejects.toHaveProperty("kind", "OpenAIAuthError");
|
|
});
|
|
|
|
it("maps 429 to OpenAIRateLimitError", async () => {
|
|
mockCreate.mockRejectedValueOnce(makeRateLimitError());
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m" })
|
|
).rejects.toHaveProperty("kind", "OpenAIRateLimitError");
|
|
});
|
|
|
|
it("maps generic error to OpenAIRequestError", async () => {
|
|
mockCreate.mockRejectedValueOnce(makeNetworkError());
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m" })
|
|
).rejects.toHaveProperty("kind", "OpenAIRequestError");
|
|
});
|
|
|
|
// ===== Security (1) =====
|
|
|
|
it("does not leak API key in error message", async () => {
|
|
const authErr = makeAuthError();
|
|
authErr.message = "Invalid OPENAI_API_KEY: sk-my-secret-key-12345.";
|
|
mockCreate.mockRejectedValueOnce(authErr);
|
|
|
|
try {
|
|
await sendOpenAIResponse(mockClient, { input: ["x"], model: "m" });
|
|
expect.fail("Should have thrown");
|
|
} catch (err) {
|
|
expect(err.message).not.toContain("sk-my-secret-key-12345");
|
|
expect(err.kind).toBe("OpenAIAuthError");
|
|
}
|
|
});
|
|
|
|
// ===== Empty content (1) =====
|
|
|
|
it("returns empty content when output_text is empty", async () => {
|
|
const client = createMockClient({ output_text: "" });
|
|
const result = await sendOpenAIResponse(client, {
|
|
input: ["x"],
|
|
model: "gpt-5.1",
|
|
});
|
|
expect(result.content).toBe("");
|
|
});
|
|
|
|
// ===== Validation: invalid maxOutputTokens (1) =====
|
|
|
|
it("throws ValidationError when maxOutputTokens is negative", async () => {
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m", maxOutputTokens: -5 })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
it("throws ValidationError when maxOutputTokens is zero", async () => {
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m", maxOutputTokens: 0 })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
it("throws ValidationError when maxOutputTokens is a non-positive float", async () => {
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m", maxOutputTokens: 0.5 })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
it("throws ValidationError when maxOutputTokens is NaN", async () => {
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m", maxOutputTokens: NaN })
|
|
).rejects.toHaveProperty("kind", "ValidationError");
|
|
});
|
|
|
|
// ===== Error mapping edge cases (2) =====
|
|
|
|
it("maps status 503 to OpenAIRequestError", async () => {
|
|
mockCreate.mockRejectedValueOnce(makeServerError());
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m" })
|
|
).rejects.toHaveProperty("kind", "OpenAIRequestError");
|
|
});
|
|
|
|
it("maps timeout error (code ETIMEDOUT) to OpenAIRequestError", async () => {
|
|
mockCreate.mockRejectedValueOnce(makeTimeoutError());
|
|
await expect(
|
|
sendOpenAIResponse(mockClient, { input: ["x"], model: "m" })
|
|
).rejects.toHaveProperty("kind", "OpenAIRequestError");
|
|
});
|
|
});
|