import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { api } from './api.js'; global.fetch = vi.fn(); function mockJsonResponse(body: unknown, init: Partial = {}): Response { return { ok: init.ok ?? true, status: init.status ?? 200, statusText: init.statusText ?? 'OK', json: vi.fn().mockResolvedValue(body), } as unknown as Response; } describe('API Client', () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); }); afterEach(() => { window.localStorage.clear(); }); describe('getServices', () => { it('fetches services successfully', async () => { const mockServices = [ { id: 'test-service', name: 'Test Service', scriptPath: '../deploy-test.sh', healthUrl: 'https://test.example.com/health', repoPath: '../test-repo', status: 'up' as const, version: '1.0.0', productId: 'devops-internal', }, ]; vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(mockServices)); const services = await api.getServices(); expect(services).toEqual(mockServices); expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:4004/api/services', expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json', }), }) ); }); it('throws the normalized API error object on fetch failure', async () => { vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse({ error: 'boom' }, { ok: false, status: 500, statusText: 'Internal Server Error', })); await expect(api.getServices()).rejects.toMatchObject({ error: 'API error: 500 Internal Server Error', status: 500, }); }); it('includes an auth token when available', async () => { window.localStorage.setItem('access_token', 'test-token'); vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse([])); await api.getServices(); expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:4004/api/services', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token', }), }) ); }); }); describe('getHermesOps', () => { it('fetches the live Hermes operations snapshot', async () => { const snapshot = { generatedAt: '2026-05-27T13:03:14.848Z', tailscaleIp: '100.87.53.10', emergencyDriveUpload: { name: 'hermes-emergency-drive-upload.timer', active: true, nextRun: 'Thu 2026-05-28 03:26:15 UTC', lastRun: null, }, instances: [], warnings: [], }; vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(snapshot)); await expect(api.getHermesOps()).resolves.toEqual(snapshot); expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:4004/api/hermes/ops', expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json', }), }), ); }); }); describe('state-changing requests', () => { it('triggers a deployment without CSRF when no user token exists', async () => { const mockResponse = { deploymentId: 'deployment-123', status: 'running', }; vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(mockResponse)); const result = await api.triggerDeployment('test-service'); expect(result).toEqual(mockResponse); expect(global.fetch).toHaveBeenCalledTimes(1); expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:4004/api/deployments/trigger/test-service', expect.objectContaining({ method: 'POST', }) ); }); it('fetches and attaches CSRF tokens for authenticated mutations', async () => { window.localStorage.setItem('access_token', 'test-token'); vi.mocked(global.fetch) .mockResolvedValueOnce(mockJsonResponse({ csrfToken: 'csrf-token' })) .mockResolvedValueOnce(mockJsonResponse({ message: 'Seeded default services' })); const result = await api.seedServices(); expect(result).toEqual({ message: 'Seeded default services' }); expect(global.fetch).toHaveBeenNthCalledWith( 1, 'http://localhost:4004/api/csrf-token', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), }) ); expect(global.fetch).toHaveBeenNthCalledWith( 2, 'http://localhost:4004/api/seed', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ Authorization: 'Bearer test-token', 'X-CSRF-Token': 'csrf-token', }), }) ); }); }); });