- puterProxyService with storeToken (create/rotate idempotent), resolveToken, chatStream - chatStream relays to Puter OpenAI-compat endpoint with SSE streaming - Cost recording with provider=puter, billingType=subscription_included, costCents=0 - Cost recording skipped when agentId is null/undefined (no FK violation) - puterProxyRoutes with POST /puter-proxy/token and POST /puter-proxy/chat - Board auth (assertBoard + assertCompanyAccess) on all routes - All 10 TDD tests passing
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { puterProxyService } from "../services/puter-proxy.js";
|
|
import { puterProxyRoutes } from "../routes/puter-proxy.js";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function makeTextEncoder() {
|
|
return new TextEncoder();
|
|
}
|
|
|
|
/**
|
|
* Build a minimal ReadableStream that emits pre-formatted SSE lines.
|
|
*/
|
|
function buildSseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
|
const enc = makeTextEncoder();
|
|
let idx = 0;
|
|
return new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (idx < chunks.length) {
|
|
controller.enqueue(enc.encode(chunks[idx++]));
|
|
} else {
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
function sseChunk(data: object): string {
|
|
return `data: ${JSON.stringify(data)}\n\n`;
|
|
}
|
|
|
|
// ─── Shared mock db factory ───────────────────────────────────────────────────
|
|
|
|
function makeMockDb(overrides: {
|
|
existingSecret?: object | null;
|
|
resolvedToken?: string;
|
|
createSecret?: ReturnType<typeof vi.fn>;
|
|
rotateSecret?: ReturnType<typeof vi.fn>;
|
|
createCostEvent?: ReturnType<typeof vi.fn>;
|
|
} = {}) {
|
|
const {
|
|
existingSecret = null,
|
|
resolvedToken = "test-bearer-token",
|
|
createSecret = vi.fn().mockResolvedValue({ id: "secret-1", name: "puter_auth_token" }),
|
|
rotateSecret = vi.fn().mockResolvedValue({ id: "secret-1", name: "puter_auth_token" }),
|
|
createCostEvent = vi.fn().mockResolvedValue({ id: "event-1" }),
|
|
} = overrides;
|
|
|
|
// We return a db object that puterProxyService will pass to secretService and costService.
|
|
// Since we mock at the module level via the db interactions, we need the service
|
|
// to call these. We'll mock the actual db calls.
|
|
return {
|
|
_mocks: { createSecret, rotateSecret, createCostEvent },
|
|
// The secretService and costService are created inside puterProxyService using the db.
|
|
// We inject mock data via the module mocking strategy below.
|
|
existingSecret,
|
|
resolvedToken,
|
|
};
|
|
}
|
|
|
|
// ─── Mock secretService and costService at module level ──────────────────────
|
|
|
|
const mockGetByName = vi.fn();
|
|
const mockCreate = vi.fn();
|
|
const mockRotate = vi.fn();
|
|
const mockResolveSecretValue = vi.fn();
|
|
const mockCreateEvent = vi.fn();
|
|
|
|
vi.mock("../services/secrets.js", () => ({
|
|
secretService: vi.fn(() => ({
|
|
getByName: mockGetByName,
|
|
create: mockCreate,
|
|
rotate: mockRotate,
|
|
resolveSecretValue: mockResolveSecretValue,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../services/costs.js", () => ({
|
|
costService: vi.fn(() => ({
|
|
createEvent: mockCreateEvent,
|
|
})),
|
|
}));
|
|
|
|
// ─── Test suite ───────────────────────────────────────────────────────────────
|
|
|
|
describe("puterProxyService", () => {
|
|
const companyId = "company-123";
|
|
const db = {} as any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// Test 1: storeToken creates a new secret when none exists
|
|
it("storeToken creates a new secret via secretService when none exists", async () => {
|
|
mockGetByName.mockResolvedValue(null);
|
|
mockCreate.mockResolvedValue({ id: "secret-1", name: "puter_auth_token" });
|
|
|
|
const svc = puterProxyService(db);
|
|
await svc.storeToken(companyId, "my-puter-token");
|
|
|
|
expect(mockGetByName).toHaveBeenCalledWith(companyId, "puter_auth_token");
|
|
expect(mockCreate).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
name: "puter_auth_token",
|
|
provider: "local_encrypted",
|
|
value: "my-puter-token",
|
|
}),
|
|
);
|
|
expect(mockRotate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Test 2: storeToken rotates existing secret
|
|
it("storeToken rotates an existing secret when puter_auth_token already exists", async () => {
|
|
const existingSecret = { id: "existing-secret-id", name: "puter_auth_token" };
|
|
mockGetByName.mockResolvedValue(existingSecret);
|
|
mockRotate.mockResolvedValue({ id: "existing-secret-id", name: "puter_auth_token" });
|
|
|
|
const svc = puterProxyService(db);
|
|
await svc.storeToken(companyId, "new-puter-token");
|
|
|
|
expect(mockGetByName).toHaveBeenCalledWith(companyId, "puter_auth_token");
|
|
expect(mockRotate).toHaveBeenCalledWith(
|
|
"existing-secret-id",
|
|
expect.objectContaining({ value: "new-puter-token" }),
|
|
);
|
|
expect(mockCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Test 3: resolveToken retrieves stored token value
|
|
it("resolveToken retrieves the stored token value via secretService.resolveSecretValue", async () => {
|
|
const existingSecret = { id: "secret-id", name: "puter_auth_token" };
|
|
mockGetByName.mockResolvedValue(existingSecret);
|
|
mockResolveSecretValue.mockResolvedValue("resolved-bearer-token");
|
|
|
|
const svc = puterProxyService(db);
|
|
const token = await svc.resolveToken(companyId);
|
|
|
|
expect(mockGetByName).toHaveBeenCalledWith(companyId, "puter_auth_token");
|
|
expect(mockResolveSecretValue).toHaveBeenCalledWith(companyId, "secret-id", "latest");
|
|
expect(token).toBe("resolved-bearer-token");
|
|
});
|
|
|
|
// Test 4: chatStream sends POST to Puter with correct headers and body
|
|
it("chatStream sends POST to Puter OpenAI-compat endpoint with Authorization Bearer header, stream: true, stream_options", async () => {
|
|
mockGetByName.mockResolvedValue({ id: "sec-1", name: "puter_auth_token" });
|
|
mockResolveSecretValue.mockResolvedValue("my-bearer-token");
|
|
mockCreateEvent.mockResolvedValue({ id: "ev-1" });
|
|
|
|
const enc = new TextEncoder();
|
|
const chunks = [
|
|
sseChunk({ choices: [{ delta: { content: "Hello" } }] }),
|
|
sseChunk({ choices: [{ delta: { content: " world" } }] }),
|
|
sseChunk({ choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 5 } }),
|
|
"data: [DONE]\n\n",
|
|
];
|
|
|
|
const mockStream = buildSseStream(chunks);
|
|
const mockResponse = {
|
|
ok: true,
|
|
body: mockStream,
|
|
};
|
|
const fetchSpy = vi.fn().mockResolvedValue(mockResponse);
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
|
|
const svc = puterProxyService(db);
|
|
const messages = [{ role: "user", content: "Hi" }];
|
|
const result: string[] = [];
|
|
for await (const chunk of svc.chatStream(companyId, "agent-1", messages, "claude-3-5-haiku-20241022", undefined)) {
|
|
result.push(chunk);
|
|
}
|
|
|
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
const [url, opts] = fetchSpy.mock.calls[0];
|
|
expect(url).toContain("api.puter.com/puterai/openai/v1/chat/completions");
|
|
expect(opts.method).toBe("POST");
|
|
expect(opts.headers["Authorization"]).toBe("Bearer my-bearer-token");
|
|
const body = JSON.parse(opts.body);
|
|
expect(body.stream).toBe(true);
|
|
expect(body.stream_options).toEqual({ include_usage: true });
|
|
});
|
|
|
|
// Test 5: chatStream yields content strings from SSE data chunks
|
|
it("chatStream yields content strings from SSE data chunks", async () => {
|
|
mockGetByName.mockResolvedValue({ id: "sec-1", name: "puter_auth_token" });
|
|
mockResolveSecretValue.mockResolvedValue("my-bearer-token");
|
|
mockCreateEvent.mockResolvedValue({ id: "ev-1" });
|
|
|
|
const chunks = [
|
|
sseChunk({ choices: [{ delta: { content: "Hello" } }] }),
|
|
sseChunk({ choices: [{ delta: { content: " world" } }] }),
|
|
sseChunk({ choices: [{ delta: {} }], usage: { prompt_tokens: 5, completion_tokens: 3 } }),
|
|
"data: [DONE]\n\n",
|
|
];
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
body: buildSseStream(chunks),
|
|
}));
|
|
|
|
const svc = puterProxyService(db);
|
|
const result: string[] = [];
|
|
for await (const chunk of svc.chatStream(companyId, "agent-1", [{ role: "user", content: "hi" }], undefined, undefined)) {
|
|
if (chunk) result.push(chunk);
|
|
}
|
|
|
|
expect(result).toContain("Hello");
|
|
expect(result).toContain(" world");
|
|
});
|
|
|
|
// Test 6: chatStream records cost event when agentId is provided
|
|
it("chatStream records a cost event with provider=puter, billingType=subscription_included, costCents=0 when agentId is provided", async () => {
|
|
mockGetByName.mockResolvedValue({ id: "sec-1", name: "puter_auth_token" });
|
|
mockResolveSecretValue.mockResolvedValue("my-bearer-token");
|
|
mockCreateEvent.mockResolvedValue({ id: "ev-1" });
|
|
|
|
const chunks = [
|
|
sseChunk({ choices: [{ delta: { content: "Hi" } }], usage: { prompt_tokens: 10, completion_tokens: 5 } }),
|
|
"data: [DONE]\n\n",
|
|
];
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
body: buildSseStream(chunks),
|
|
}));
|
|
|
|
const svc = puterProxyService(db);
|
|
for await (const _ of svc.chatStream(companyId, "agent-xyz", [{ role: "user", content: "hi" }], "claude-3-5-haiku-20241022", undefined)) {
|
|
// consume stream
|
|
}
|
|
|
|
// Allow microtask queue to flush (non-blocking cost recording)
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
expect(mockCreateEvent).toHaveBeenCalledOnce();
|
|
expect(mockCreateEvent).toHaveBeenCalledWith(
|
|
companyId,
|
|
expect.objectContaining({
|
|
agentId: "agent-xyz",
|
|
provider: "puter",
|
|
billingType: "subscription_included",
|
|
costCents: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Test 7: chatStream skips cost recording when agentId is null/undefined
|
|
it("chatStream skips cost recording when agentId is null/undefined", async () => {
|
|
mockGetByName.mockResolvedValue({ id: "sec-1", name: "puter_auth_token" });
|
|
mockResolveSecretValue.mockResolvedValue("my-bearer-token");
|
|
|
|
const chunks = [
|
|
sseChunk({ choices: [{ delta: { content: "No cost" } }] }),
|
|
"data: [DONE]\n\n",
|
|
];
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
body: buildSseStream(chunks),
|
|
}));
|
|
|
|
const svc = puterProxyService(db);
|
|
for await (const _ of svc.chatStream(companyId, null, [{ role: "user", content: "hi" }], undefined, undefined)) {
|
|
// consume
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(mockCreateEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── Route tests ─────────────────────────────────────────────────────────────
|
|
|
|
function buildTestApp(db: any) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
// Inject a board actor for all requests
|
|
app.use((req: any, _res: any, next: any) => {
|
|
req.actor = {
|
|
type: "board",
|
|
source: "local_implicit",
|
|
companyIds: ["company-123"],
|
|
isInstanceAdmin: true,
|
|
};
|
|
next();
|
|
});
|
|
app.use(puterProxyRoutes(db));
|
|
return app;
|
|
}
|
|
|
|
describe("puterProxyRoutes", () => {
|
|
const db = {} as any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// Test 8: POST /api/puter-proxy/token stores token and returns 200
|
|
it("POST /puter-proxy/token stores token and returns 200", async () => {
|
|
mockGetByName.mockResolvedValue(null);
|
|
mockCreate.mockResolvedValue({ id: "sec-1", name: "puter_auth_token" });
|
|
|
|
const app = buildTestApp(db);
|
|
const res = await request(app)
|
|
.post("/puter-proxy/token")
|
|
.send({ companyId: "company-123", token: "puter-abc123" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: true });
|
|
expect(mockCreate).toHaveBeenCalledWith(
|
|
"company-123",
|
|
expect.objectContaining({ name: "puter_auth_token", value: "puter-abc123" }),
|
|
);
|
|
});
|
|
|
|
// Test 9: POST /api/puter-proxy/chat sets SSE headers and streams response chunks
|
|
it("POST /puter-proxy/chat sets SSE headers and streams response chunks", async () => {
|
|
mockGetByName.mockResolvedValue({ id: "sec-1", name: "puter_auth_token" });
|
|
mockResolveSecretValue.mockResolvedValue("bearer-xyz");
|
|
mockCreateEvent.mockResolvedValue({ id: "ev-1" });
|
|
|
|
const chunks = [
|
|
sseChunk({ choices: [{ delta: { content: "Stream token" } }] }),
|
|
"data: [DONE]\n\n",
|
|
];
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
body: buildSseStream(chunks),
|
|
}));
|
|
|
|
const app = buildTestApp(db);
|
|
const res = await request(app)
|
|
.post("/puter-proxy/chat")
|
|
.send({
|
|
companyId: "company-123",
|
|
agentId: "agent-1",
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers["content-type"]).toMatch(/text\/event-stream/);
|
|
expect(res.text).toContain("Stream token");
|
|
});
|
|
|
|
// Test 10: POST /api/puter-proxy/chat works without agentId in request body
|
|
it("POST /puter-proxy/chat works without agentId in request body (agentId is optional)", async () => {
|
|
mockGetByName.mockResolvedValue({ id: "sec-1", name: "puter_auth_token" });
|
|
mockResolveSecretValue.mockResolvedValue("bearer-xyz");
|
|
|
|
const chunks = [
|
|
sseChunk({ choices: [{ delta: { content: "No agent" } }] }),
|
|
"data: [DONE]\n\n",
|
|
];
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
body: buildSseStream(chunks),
|
|
}));
|
|
|
|
const app = buildTestApp(db);
|
|
const res = await request(app)
|
|
.post("/puter-proxy/chat")
|
|
.send({
|
|
companyId: "company-123",
|
|
// No agentId
|
|
messages: [{ role: "user", content: "Hello without agent" }],
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers["content-type"]).toMatch(/text\/event-stream/);
|
|
expect(res.text).toContain("No agent");
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
// No cost event since no agentId
|
|
expect(mockCreateEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|