Merge commit '1e6520aba25a10aea98766de7d0e9892b291c612'

This commit is contained in:
Alexa Amundson
2025-11-23 18:01:56 -06:00
2 changed files with 82 additions and 0 deletions

50
src/lib/apiClient.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiGet } from './apiClient';
const originalEnv = process.env.NEXT_PUBLIC_API_BASE_URL;
beforeEach(() => {
vi.restoreAllMocks();
process.env.NEXT_PUBLIC_API_BASE_URL = originalEnv;
});
afterEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
process.env.NEXT_PUBLIC_API_BASE_URL = originalEnv;
});
describe('apiClient', () => {
it('throws when base URL is missing', async () => {
delete process.env.NEXT_PUBLIC_API_BASE_URL;
await expect(apiGet('/internal/agents')).rejects.toThrow('NEXT_PUBLIC_API_BASE_URL is not configured');
});
it('normalizes the request URL and returns JSON', async () => {
process.env.NEXT_PUBLIC_API_BASE_URL = 'https://api.example.com/';
const payload = { ok: true };
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => payload
});
vi.stubGlobal('fetch', fetchMock);
const result = await apiGet('internal/agents');
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/internal/agents', {
method: 'GET',
headers: { 'content-type': 'application/json' }
});
expect(result).toEqual(payload);
});
it('wraps network failures with URL context', async () => {
process.env.NEXT_PUBLIC_API_BASE_URL = 'https://api.example.com';
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connection refused')));
await expect(apiGet('/internal/agents')).rejects.toThrow(
'Failed to reach API at https://api.example.com/internal/agents: connection refused'
);
});
});

View File

@@ -79,6 +79,38 @@ export async function getRoadChainBlocks(): Promise<RoadChainBlock[]> {
} catch (error) { } catch (error) {
console.warn('[api] falling back to mock roadchain blocks', error); console.warn('[api] falling back to mock roadchain blocks', error);
return mockRoadChainBlocks; return mockRoadChainBlocks;
function buildApiUrl(path: string): string {
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (!baseUrl) {
throw new Error('NEXT_PUBLIC_API_BASE_URL is not configured. Set it to your API endpoint.');
}
const normalizedBase = baseUrl.replace(/\/$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${normalizedBase}${normalizedPath}`;
}
export async function apiGet<T>(path: string): Promise<T> {
const url = buildApiUrl(path);
let res: Response;
try {
res = await fetch(url, {
method: 'GET',
headers: {
'content-type': 'application/json'
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to reach API at ${url}: ${message}`);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
const body = text || res.statusText;
throw new Error(`API error ${res.status} from ${url}: ${body}`);
} }
} }