/** * @vitest-environment happy-dom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { api } from './client'; import { clearStoredAuth, setStoredAuth } from './auth'; import { mockAgent, mockServerConfig, mockServerInfo } from '../test/fixtures'; function jsonResponse(data: unknown, status = 200) { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' }, }); } function textResponse(body: string, status: number) { return new Response(body, { status }); } describe('api client', () => { let fetchMock: ReturnType; beforeEach(() => { sessionStorage.clear(); fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); function lastFetch(): { url: string; init: RequestInit } { const [url, init] = fetchMock.mock.calls.at(-1)!; return { url: url as string, init: init as RequestInit }; } function expectAuthHeaders(init: RequestInit) { const headers = init.headers as Record; expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`); } it('sends JSON Content-Type and auth on listAgents', async () => { setStoredAuth('user', 'pass'); fetchMock.mockResolvedValueOnce(jsonResponse([mockAgent()])); const agents = await api.listAgents(); expect(agents).toHaveLength(1); const { url, init } = lastFetch(); expect(url).toBe('/api/v1/agents'); expect(init.method).toBeUndefined(); expect((init.headers as Record)['Content-Type']).toBe('application/json'); expectAuthHeaders(init); }); it('throws with status and body on API errors', async () => { fetchMock.mockResolvedValueOnce(textResponse('not found', 404)); await expect(api.getAgent('missing')).rejects.toThrow('API error 404: not found'); expect(lastFetch().url).toBe('/api/v1/agents/missing'); }); it('omits Authorization when logged out', async () => { clearStoredAuth(); fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'ok' })); await api.healthCheck(); const headers = lastFetch().init.headers as Record; expect(headers.Authorization).toBeUndefined(); }); it('getAgentStats appends limit query param', async () => { fetchMock.mockResolvedValueOnce(jsonResponse([])); await api.getAgentStats('agent-1', 25); expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats?limit=25'); }); it('getAgentStats omits limit when undefined', async () => { fetchMock.mockResolvedValueOnce(jsonResponse([])); await api.getAgentStats('agent-1'); expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats'); }); it('getRecentShares appends limit query param', async () => { fetchMock.mockResolvedValueOnce(jsonResponse([])); await api.getRecentShares(10); expect(lastFetch().url).toBe('/api/v1/shares?limit=10'); }); it('updateConfig PUTs JSON body', async () => { const partial = { server: { dashboard_subtitle: 'test' } }; fetchMock.mockResolvedValueOnce(jsonResponse(mockServerConfig(partial))); await api.updateConfig(partial); const { url, init } = lastFetch(); expect(url).toBe('/api/v1/config'); expect(init.method).toBe('PUT'); expect(init.body).toBe(JSON.stringify(partial)); }); it('buildAgent POSTs JSON when fusion disabled', async () => { const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters[0]; fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b1', success: true })); await api.buildAgent(req); const { url, init } = lastFetch(); expect(url).toBe('/api/v1/builder/build'); expect(init.method).toBe('POST'); expect(init.body).toBe(JSON.stringify(req)); }); it('buildAgent rejects fusion without prep file', async () => { const req = { fusion_enabled: true } as Parameters[0]; await expect(api.buildAgent(req)).rejects.toThrow('Fusion requires prep.exe upload'); expect(fetchMock).not.toHaveBeenCalled(); }); it('buildAgent POSTs multipart when fusion enabled', async () => { setStoredAuth('user', 'pass'); const req = { fusion_enabled: true, wallet: '4' + 'A'.repeat(94) } as Parameters[0]; const prep = new File(['prep'], 'custom.exe', { type: 'application/octet-stream' }); fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b2', success: true })); await api.buildAgent(req, prep); const { url, init } = lastFetch(); expect(url).toBe('/api/v1/builder/build'); expect(init.method).toBe('POST'); expect(init.body).toBeInstanceOf(FormData); const headers = init.headers as Record; expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`); expect(headers['Content-Type']).toBeUndefined(); const form = init.body as FormData; expect(form.get('config')).toBe(JSON.stringify(req)); expect(form.get('prep_exe')).toBeInstanceOf(File); }); it('estimateFusion POSTs multipart with auth', async () => { setStoredAuth('user', 'pass'); const req = { fusion_enabled: true } as Parameters[0]; const prep = new File(['prep'], 'prep.exe'); fetchMock.mockResolvedValueOnce(jsonResponse({ estimated_size_mb: 12 })); await api.estimateFusion(req, prep); const { url, init } = lastFetch(); expect(url).toBe('/api/v1/builder/estimate'); expect(init.method).toBe('POST'); expectAuthHeaders(init); }); it('estimateFusion rejects without prep file', async () => { const req = { fusion_enabled: true } as Parameters[0]; await expect(api.estimateFusion(req, null)).rejects.toThrow('Fusion requires prep.exe upload'); expect(fetchMock).not.toHaveBeenCalled(); }); it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => { fetchMock .mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' })) .mockResolvedValueOnce(jsonResponse({ ok: true })) .mockResolvedValueOnce(jsonResponse({ ok: true, deleted_id: 'b1' })); await api.pinBuild('b1'); expect(lastFetch().url).toBe('/api/v1/builds/b1/pin'); expect(lastFetch().init.method).toBe('PUT'); await api.unpinAll(); expect(lastFetch().url).toBe('/api/v1/builds/pin'); expect(lastFetch().init.method).toBe('DELETE'); await api.deleteBuild('b1'); expect(lastFetch().url).toBe('/api/v1/builds/b1'); expect(lastFetch().init.method).toBe('DELETE'); }); it('build URL helpers encode paths', () => { expect(api.buildDownloadUrl('id-1')).toBe('/api/v1/builds/id-1/download'); expect(api.buildArtifactUrl('id-1', 'file with spaces.exe')).toBe( '/api/v1/builds/id-1/artifact/file%20with%20spaces.exe' ); expect(api.buildUninstallUrl('id-1')).toBe('/api/v1/builds/id-1/uninstall'); }); it('blueprint CRUD uses encoded names', async () => { fetchMock .mockResolvedValueOnce(jsonResponse([])) .mockResolvedValueOnce(jsonResponse({ name: 'preset' })) .mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset', file_path: '/x', created_at: '' })) .mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset' })); await api.listBlueprints(); expect(lastFetch().url).toBe('/api/v1/blueprints'); await api.getBlueprint('my preset'); expect(lastFetch().url).toBe('/api/v1/blueprints/my%20preset'); await api.saveBlueprint('preset', { foo: 1 }); expect(lastFetch().url).toBe('/api/v1/blueprints'); expect(JSON.parse(lastFetch().init.body as string)).toEqual({ name: 'preset', data: { foo: 1 } }); await api.deleteBlueprint('my preset'); expect(lastFetch().url).toBe('/api/v1/blueprints?name=my%20preset'); expect(lastFetch().init.method).toBe('DELETE'); }); it('fleet ops endpoints hit expected paths', async () => { fetchMock .mockResolvedValueOnce(jsonResponse([])) .mockResolvedValueOnce(jsonResponse([])) .mockResolvedValueOnce(jsonResponse([])) .mockResolvedValueOnce(jsonResponse({ xmr_per_day: 0.01, usd_per_day: 1, network_hashrate: 1 })) .mockResolvedValueOnce(jsonResponse({ success: true })) .mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' })) .mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'fresh' })) .mockResolvedValueOnce(jsonResponse({ success: true, agent: mockAgent() })) .mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' })); await api.getAlerts(); expect(lastFetch().url).toBe('/api/v1/alerts'); await api.getPoolStatus(); expect(lastFetch().url).toBe('/api/v1/pools/status'); await api.getAIActivity(); expect(lastFetch().url).toBe('/api/v1/ai/activity'); await api.getEarningsEstimate(1234.5); expect(lastFetch().url).toBe('/api/v1/earnings/estimate?hashrate=1234.5'); await api.sendAgentCommand('a1', 'pause', { reason: 'test' }); expect(lastFetch().url).toBe('/api/v1/agents/a1/command'); expect(JSON.parse(lastFetch().init.body as string)).toEqual({ action: 'pause', reason: 'test' }); await api.getAgentLog('a1'); expect(lastFetch().url).toBe('/api/v1/agents/a1/log'); await api.getAgentLog('a1', true); expect(lastFetch().url).toBe('/api/v1/agents/a1/log?refresh=1'); await api.updateAgentMeta('a1', 'notes', ['tag1']); expect(lastFetch().url).toBe('/api/v1/agents/a1/meta'); expect(JSON.parse(lastFetch().init.body as string)).toEqual({ notes: 'notes', tags: ['tag1'] }); await api.sendBulkCommand(['a1', 'a2'], 'resume'); expect(lastFetch().url).toBe('/api/v1/agents/bulk-command'); expect(JSON.parse(lastFetch().init.body as string)).toEqual({ agent_ids: ['a1', 'a2'], action: 'resume', }); }); it('createUser POSTs credentials', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ success: true })); await api.createUser('alice', 'secret'); expect(lastFetch().url).toBe('/api/v1/users'); expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' }); }); it('rotateFleetSecret POSTs rotate endpoint', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, hint: 'abcd1234...' })); const res = await api.rotateFleetSecret(); expect(res.ok).toBe(true); expect(lastFetch().url).toBe('/api/v1/server/rotate-secret'); expect(lastFetch().init.method).toBe('POST'); }); it('getXmrPrice and getServerInfo', async () => { fetchMock .mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' })) .mockResolvedValueOnce(jsonResponse(mockServerInfo)); await api.getXmrPrice(); expect(lastFetch().url).toBe('/api/v1/market/xmr'); await api.getServerInfo(); expect(lastFetch().url).toBe('/api/v1/server/info'); }); it('cancelBuild DELETEs encoded token', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ cancelled: true })); await api.cancelBuild('token/with/slash'); expect(lastFetch().url).toBe('/api/v1/builder/cancel/token%2Fwith%2Fslash'); expect(lastFetch().init.method).toBe('DELETE'); }); it('getDashboardStats and listBuilds', async () => { fetchMock .mockResolvedValueOnce( jsonResponse({ total_agents: 1, online_agents: 1, total_hashrate: 100, total_shares: 5 }) ) .mockResolvedValueOnce(jsonResponse([])); await api.getDashboardStats(); expect(lastFetch().url).toBe('/api/v1/dashboard/stats'); await api.listBuilds(); expect(lastFetch().url).toBe('/api/v1/builds'); }); });