diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts new file mode 100644 index 0000000..f1e9fcb --- /dev/null +++ b/apps/web/lib/api.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { __resetApiAvailabilityForTests, api, resolveApiBase } from './api'; + +test('API base defaults to same-origin in production and localhost in development', () => { + assert.equal(resolveApiBase({ NODE_ENV: 'production' }), '/api/v1'); + assert.equal(resolveApiBase({ NODE_ENV: 'development' }), 'http://localhost:3001/api/v1'); + assert.equal( + resolveApiBase({ NODE_ENV: 'production', NEXT_PUBLIC_API_URL: 'https://api.example.com/api/v1/' }), + 'https://api.example.com/api/v1', + ); +}); + +test('API requests use the resolved base path', async () => { + const originalFetch = globalThis.fetch; + const apiBase = resolveApiBase(); + const urls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + urls.push(String(input)); + return new Response(JSON.stringify(init?.method === 'POST' ? { ok: true } : {}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + __resetApiAvailabilityForTests(); + await api.post('/config/ai/test', { id: 'provider-1' }); + assert.deepEqual(urls, [`${apiBase}/config/ai`, `${apiBase}/config/ai/test`]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('API availability probe retries after a transient failure', async () => { + const originalFetch = globalThis.fetch; + const apiBase = resolveApiBase(); + const calls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push(String(input)); + if (calls.length === 1) throw new Error('temporary network failure'); + return new Response(JSON.stringify(init?.method === 'POST' ? { ok: true } : {}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + __resetApiAvailabilityForTests(); + await assert.rejects(() => api.post('/config/ai/test', { id: 'provider-1' }), /API 不可用/); + await api.post('/config/ai/test', { id: 'provider-1' }); + assert.deepEqual(calls, [ + `${apiBase}/config/ai`, + `${apiBase}/config/ai`, + `${apiBase}/config/ai/test`, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index b8b48e4..30a4e0c 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,4 +1,14 @@ -const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; +type ApiEnv = { + NEXT_PUBLIC_API_URL?: string; + NODE_ENV?: string; +}; + +export function resolveApiBase(env: ApiEnv = process.env) { + const fallback = env.NODE_ENV === 'production' ? '/api/v1' : 'http://localhost:3001/api/v1'; + return (env.NEXT_PUBLIC_API_URL || fallback).replace(/\/$/, ''); +} + +const API_BASE = resolveApiBase(); let apiAvailable: boolean | null = null; let probePromise: Promise | null = null; @@ -23,17 +33,19 @@ export class ApiRequestError extends Error { } async function checkApi(): Promise { - if (apiAvailable !== null) return apiAvailable; + if (apiAvailable === true) return true; if (probePromise) return probePromise; probePromise = (async () => { try { // 探测一个不依赖数据库的端点(/config/ai 总是返回 200,只要 NestJS 起来了) const res = await fetch(`${API_BASE}/config/ai`, { method: 'GET', signal: AbortSignal.timeout(1500) }); - apiAvailable = res.ok; + apiAvailable = res.ok ? true : null; } catch { - apiAvailable = false; + apiAvailable = null; + } finally { + probePromise = null; } - return apiAvailable; + return apiAvailable === true; })(); return probePromise; } @@ -45,7 +57,10 @@ async function request(path: string, options?: RequestInit): Promise { headers: { 'Content-Type': 'application/json', ...options?.headers }, ...options, }).catch(() => null); - if (!res) throw new Error('API 不可用'); + if (!res) { + apiAvailable = null; + throw new Error('API 不可用'); + } if (!res.ok) { const error = await res.json().catch(() => ({})); throw new ApiRequestError(res.status, error); @@ -83,3 +98,8 @@ export const api = { } }, }; + +export function __resetApiAvailabilityForTests() { + apiAvailable = null; + probePromise = null; +}