Files
AetherForge/server/web/src/api/client.test.ts
AetherForge ea6f54ad03 Expand test coverage across server, agent, and web; fix bugs found during audit.
Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
2026-05-31 01:13:58 -07:00

301 lines
11 KiB
TypeScript

/**
* @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<typeof vi.fn>;
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<string, string>;
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<string, string>)['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<string, string>;
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<typeof api.buildAgent>[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<typeof api.buildAgent>[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<typeof api.buildAgent>[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<string, string>;
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<typeof api.estimateFusion>[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<typeof api.estimateFusion>[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('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');
});
});