Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
311 lines
12 KiB
TypeScript
311 lines
12 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('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');
|
|
});
|
|
});
|