Files
AetherForge/server/web/src/api/client.test.ts
AetherForge b0240abecc
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Fix Emberwake downloads by appending blob anchors before click.
ZIP exports and public download links silently failed because blob saves skipped document.body appendChild (unlike Builds DownloadButton) and public links pointed at the command-deck URL instead of same-origin paths.
2026-06-07 15:54:28 -07:00

365 lines
14 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();
localStorage.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')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
}
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();
expect(headers['X-AetherForge-Client']).toBe('dashboard');
});
it('clears stored auth on 401 API response', async () => {
setStoredAuth('user', 'pass');
fetchMock.mockResolvedValueOnce(textResponse('Unauthorized', 401));
await expect(api.listAgents()).rejects.toThrow('API error 401');
expect(sessionStorage.getItem('aetherforge_auth')).toBeNull();
expect(localStorage.getItem('aetherforge_auth')).toBeNull();
});
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));
expect(init.signal).toBeDefined();
});
it('buildAgent surfaces server error from JSON body', async () => {
const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => JSON.stringify({ success: false, error: 'compile failed (garble): OOM' }),
});
await expect(api.buildAgent(req)).rejects.toThrow('compile failed (garble): OOM');
});
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({ models: ['llama3.2'] }))
.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.getAIModels('http://127.0.0.1:11434/v1');
expect(lastFetch().url).toBe('/api/v1/ai/models?endpoint=http%3A%2F%2F127.0.0.1%3A11434%2Fv1');
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('exportSpreadKit POSTs and triggers blob download', async () => {
setStoredAuth('user', 'pass');
fetchMock.mockResolvedValueOnce(new Response(new Blob(['zip']), { status: 200 }));
const appendChildSpy = vi.spyOn(document.body, 'appendChild');
const clickMock = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
await api.exportSpreadKit({
build_id: 'build-1',
server_url: 'http://localhost:8989',
campaign: 'linkedin-bait',
});
const { url, init } = lastFetch();
expect(url).toBe('/api/v1/builder/spread-kit-export');
expect(init.method).toBe('POST');
expectAuthHeaders(init);
expect(JSON.parse(init.body as string)).toEqual({
build_id: 'build-1',
server_url: 'http://localhost:8989',
campaign: 'linkedin-bait',
});
expect(appendChildSpy).toHaveBeenCalled();
expect(clickMock).toHaveBeenCalled();
});
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');
});
});