|
|
import { describe, it, expect, beforeAll, afterAll } from "bun:test"; |
|
|
|
|
|
const BASE_URL = "http://localhost:3002"; |
|
|
let server: any; |
|
|
|
|
|
beforeAll(async () => { |
|
|
|
|
|
const { spawn } = require("child_process"); |
|
|
server = spawn("bun", ["run", "src/server.ts"], { |
|
|
env: { ...process.env, PORT: "3002" }, |
|
|
stdio: "pipe", |
|
|
}); |
|
|
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 3000)); |
|
|
|
|
|
|
|
|
let retries = 5; |
|
|
while (retries > 0) { |
|
|
try { |
|
|
const response = await fetch(`${BASE_URL}/health`); |
|
|
if (response.ok) break; |
|
|
} catch (e) { |
|
|
|
|
|
} |
|
|
retries--; |
|
|
await new Promise((resolve) => setTimeout(resolve, 1000)); |
|
|
} |
|
|
}); |
|
|
|
|
|
afterAll(() => { |
|
|
if (server) { |
|
|
server.kill(); |
|
|
} |
|
|
}); |
|
|
|
|
|
describe("OpenAI Compatible Server", () => { |
|
|
describe("Health Check", () => { |
|
|
it("should return health status", async () => { |
|
|
const response = await fetch(`${BASE_URL}/health`); |
|
|
expect(response.status).toBe(200); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data).toEqual({ status: "ok" }); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("Models Endpoint", () => { |
|
|
it("should return list of available models", async () => { |
|
|
const response = await fetch(`${BASE_URL}/v1/models`); |
|
|
expect(response.status).toBe(200); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data.object).toBe("list"); |
|
|
expect(Array.isArray(data.data)).toBe(true); |
|
|
expect(data.data.length).toBeGreaterThan(0); |
|
|
|
|
|
|
|
|
const model = data.data[0]; |
|
|
expect(model).toHaveProperty("id"); |
|
|
expect(model).toHaveProperty("object", "model"); |
|
|
expect(model).toHaveProperty("created"); |
|
|
expect(model).toHaveProperty("owned_by", "duckai"); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("Chat Completions", () => { |
|
|
it("should handle basic chat completion", async () => { |
|
|
const requestBody = { |
|
|
model: "gpt-4o-mini", |
|
|
messages: [{ role: "user", content: "Say hello" }], |
|
|
}; |
|
|
|
|
|
const response = await fetch(`${BASE_URL}/v1/chat/completions`, { |
|
|
method: "POST", |
|
|
headers: { "Content-Type": "application/json" }, |
|
|
body: JSON.stringify(requestBody), |
|
|
}); |
|
|
|
|
|
expect(response.status).toBe(200); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data).toHaveProperty("id"); |
|
|
expect(data).toHaveProperty("object", "chat.completion"); |
|
|
expect(data).toHaveProperty("created"); |
|
|
expect(data).toHaveProperty("model", "gpt-4o-mini"); |
|
|
expect(data).toHaveProperty("choices"); |
|
|
expect(data).toHaveProperty("usage"); |
|
|
|
|
|
|
|
|
expect(Array.isArray(data.choices)).toBe(true); |
|
|
expect(data.choices.length).toBe(1); |
|
|
|
|
|
const choice = data.choices[0]; |
|
|
expect(choice).toHaveProperty("index", 0); |
|
|
expect(choice).toHaveProperty("message"); |
|
|
expect(choice).toHaveProperty("finish_reason", "stop"); |
|
|
|
|
|
|
|
|
expect(choice.message).toHaveProperty("role", "assistant"); |
|
|
expect(choice.message).toHaveProperty("content"); |
|
|
expect(typeof choice.message.content).toBe("string"); |
|
|
|
|
|
expect(choice.message.content.length).toBeGreaterThanOrEqual(0); |
|
|
|
|
|
|
|
|
expect(data.usage).toHaveProperty("prompt_tokens"); |
|
|
expect(data.usage).toHaveProperty("completion_tokens"); |
|
|
expect(data.usage).toHaveProperty("total_tokens"); |
|
|
expect(typeof data.usage.prompt_tokens).toBe("number"); |
|
|
expect(typeof data.usage.completion_tokens).toBe("number"); |
|
|
expect(typeof data.usage.total_tokens).toBe("number"); |
|
|
}); |
|
|
|
|
|
it("should handle streaming chat completion", async () => { |
|
|
const requestBody = { |
|
|
model: "gpt-4o-mini", |
|
|
messages: [{ role: "user", content: "Count to 3" }], |
|
|
stream: true, |
|
|
}; |
|
|
|
|
|
const response = await fetch(`${BASE_URL}/v1/chat/completions`, { |
|
|
method: "POST", |
|
|
headers: { "Content-Type": "application/json" }, |
|
|
body: JSON.stringify(requestBody), |
|
|
}); |
|
|
|
|
|
expect(response.status).toBe(200); |
|
|
expect(response.headers.get("content-type")).toBe("text/event-stream"); |
|
|
|
|
|
const reader = response.body?.getReader(); |
|
|
expect(reader).toBeDefined(); |
|
|
|
|
|
if (reader) { |
|
|
const decoder = new TextDecoder(); |
|
|
let chunks: string[] = []; |
|
|
let done = false; |
|
|
|
|
|
while (!done) { |
|
|
const { value, done: readerDone } = await reader.read(); |
|
|
done = readerDone; |
|
|
|
|
|
if (value) { |
|
|
const chunk = decoder.decode(value); |
|
|
chunks.push(chunk); |
|
|
} |
|
|
} |
|
|
|
|
|
const fullResponse = chunks.join(""); |
|
|
expect(fullResponse).toContain("data: "); |
|
|
expect(fullResponse).toContain("[DONE]"); |
|
|
|
|
|
|
|
|
const lines = fullResponse.split("\n"); |
|
|
const firstDataLine = lines.find( |
|
|
(line) => line.startsWith("data: ") && !line.includes("[DONE]") |
|
|
); |
|
|
expect(firstDataLine).toBeDefined(); |
|
|
|
|
|
if (firstDataLine) { |
|
|
const jsonStr = firstDataLine.replace("data: ", ""); |
|
|
const data = JSON.parse(jsonStr); |
|
|
|
|
|
expect(data).toHaveProperty("id"); |
|
|
expect(data).toHaveProperty("object", "chat.completion.chunk"); |
|
|
expect(data).toHaveProperty("created"); |
|
|
expect(data).toHaveProperty("model", "gpt-4o-mini"); |
|
|
expect(data).toHaveProperty("choices"); |
|
|
|
|
|
const choice = data.choices[0]; |
|
|
expect(choice).toHaveProperty("index", 0); |
|
|
expect(choice).toHaveProperty("delta"); |
|
|
|
|
|
if (choice.delta.role) { |
|
|
expect(choice.delta.role).toBe("assistant"); |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
it("should validate required fields", async () => { |
|
|
const response = await fetch(`${BASE_URL}/v1/chat/completions`, { |
|
|
method: "POST", |
|
|
headers: { "Content-Type": "application/json" }, |
|
|
body: JSON.stringify({}), |
|
|
}); |
|
|
|
|
|
expect(response.status).toBe(400); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data).toHaveProperty("error"); |
|
|
expect(data.error).toHaveProperty("message"); |
|
|
expect(data.error).toHaveProperty("type", "invalid_request_error"); |
|
|
}); |
|
|
|
|
|
it("should validate message structure", async () => { |
|
|
const response = await fetch(`${BASE_URL}/v1/chat/completions`, { |
|
|
method: "POST", |
|
|
headers: { "Content-Type": "application/json" }, |
|
|
body: JSON.stringify({ |
|
|
model: "gpt-4o-mini", |
|
|
messages: [{ role: "invalid", content: "test" }], |
|
|
}), |
|
|
}); |
|
|
|
|
|
expect(response.status).toBe(400); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data.error.message).toContain("valid role"); |
|
|
}); |
|
|
|
|
|
it("should handle multiple messages", async () => { |
|
|
const requestBody = { |
|
|
model: "gpt-4o-mini", |
|
|
messages: [{ role: "user", content: "What is 2+2?" }], |
|
|
}; |
|
|
|
|
|
const response = await fetch(`${BASE_URL}/v1/chat/completions`, { |
|
|
method: "POST", |
|
|
headers: { "Content-Type": "application/json" }, |
|
|
body: JSON.stringify(requestBody), |
|
|
}); |
|
|
|
|
|
expect(response.status).toBe(200); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data.choices[0].message.content).toBeDefined(); |
|
|
expect(typeof data.choices[0].message.content).toBe("string"); |
|
|
|
|
|
expect(data.choices[0].message.role).toBe("assistant"); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("CORS", () => { |
|
|
it("should handle preflight requests", async () => { |
|
|
const response = await fetch(`${BASE_URL}/v1/chat/completions`, { |
|
|
method: "OPTIONS", |
|
|
}); |
|
|
|
|
|
expect(response.status).toBe(200); |
|
|
expect(response.headers.get("access-control-allow-origin")).toBe("*"); |
|
|
expect(response.headers.get("access-control-allow-methods")).toContain( |
|
|
"POST" |
|
|
); |
|
|
expect(response.headers.get("access-control-allow-headers")).toContain( |
|
|
"Content-Type" |
|
|
); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("Error Handling", () => { |
|
|
it("should return 404 for unknown endpoints", async () => { |
|
|
const response = await fetch(`${BASE_URL}/unknown`); |
|
|
expect(response.status).toBe(404); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data.error.message).toBe("Not found"); |
|
|
expect(data.error.type).toBe("invalid_request_error"); |
|
|
}); |
|
|
|
|
|
it("should handle malformed JSON", async () => { |
|
|
const response = await fetch(`${BASE_URL}/v1/chat/completions`, { |
|
|
method: "POST", |
|
|
headers: { "Content-Type": "application/json" }, |
|
|
body: "invalid json", |
|
|
}); |
|
|
|
|
|
expect(response.status).toBe(500); |
|
|
|
|
|
const data = await response.json(); |
|
|
expect(data).toHaveProperty("error"); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|