diff --git a/web/src/app/api/research/_base.test.ts b/web/src/app/api/research/_base.test.ts new file mode 100644 index 0000000..bdbe9e6 --- /dev/null +++ b/web/src/app/api/research/_base.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest" + +const { sharedApiBaseUrlMock, sharedProxyJsonMock } = vi.hoisted(() => ({ + sharedApiBaseUrlMock: vi.fn(() => "https://backend.test"), + sharedProxyJsonMock: vi.fn(), +})) + +vi.mock("../_utils/backend-proxy", () => ({ + apiBaseUrl: sharedApiBaseUrlMock, + proxyJson: sharedProxyJsonMock, +})) + +import { apiBaseUrl, proxyJson } from "./_base" + +describe("research base proxy", () => { + it("delegates backend base URL resolution to the shared helper", () => { + expect(apiBaseUrl()).toBe("https://backend.test") + expect(sharedApiBaseUrlMock).toHaveBeenCalledTimes(1) + }) + + it("forces backend auth when delegating JSON proxies", async () => { + const req = new Request("https://localhost/api/research/tracks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "demo" }), + }) + const response = Response.json({ ok: true }, { status: 201 }) + sharedProxyJsonMock.mockResolvedValueOnce(response) + + const res = await proxyJson(req, "https://backend/api/research/tracks", "POST") + + expect(sharedProxyJsonMock).toHaveBeenCalledWith( + req, + "https://backend/api/research/tracks", + "POST", + { auth: true }, + ) + expect(res).toBe(response) + }) +}) diff --git a/web/src/app/api/research/_base.ts b/web/src/app/api/research/_base.ts index fad9f1a..54bafb0 100644 --- a/web/src/app/api/research/_base.ts +++ b/web/src/app/api/research/_base.ts @@ -1,47 +1,17 @@ -export function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { + apiBaseUrl as sharedApiBaseUrl, + proxyJson as sharedProxyJson, + type ProxyMethod, +} from "../_utils/backend-proxy" -export async function proxyJson(req: Request, upstreamUrl: string, method: string) { - const body = method === "GET" ? undefined : await req.text() - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 120_000) // 2 min timeout +export function apiBaseUrl(): string { + return sharedApiBaseUrl() +} - try { - const baseHeaders = { - method, - Accept: "application/json", - "Content-Type": req.headers.get("content-type") || "application/json", - } as Record - const { withBackendAuth } = await import("../_utils/auth-headers") - const headers = await withBackendAuth(req, baseHeaders) - const upstream = await fetch(upstreamUrl, { - method, - headers, - body, - signal: controller.signal, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - const isTimeout = error instanceof Error && error.name === "AbortError" - return Response.json( - { - detail: isTimeout - ? `Upstream API timed out: ${upstreamUrl}` - : `Upstream API unreachable: ${upstreamUrl}`, - error: detail, - }, - { status: 502 }, - ) - } finally { - clearTimeout(timeout) - } +export function proxyJson( + req: Request, + upstreamUrl: string, + method: ProxyMethod, +): Promise { + return sharedProxyJson(req, upstreamUrl, method, { auth: true }) }