Files
Remote-Terminal-for-MeshCore/frontend/src/test/api.test.ts
2026-03-13 17:55:17 -07:00

394 lines
11 KiB
TypeScript

/**
* Tests for API utilities.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { isAbortError, api } from '../api';
describe('isAbortError', () => {
it('returns true for AbortError', () => {
const controller = new AbortController();
controller.abort();
// Create an error that mimics fetch abort
const error = new DOMException('The operation was aborted', 'AbortError');
expect(isAbortError(error)).toBe(true);
});
it('returns true for Error with name AbortError', () => {
const error = new Error('Request cancelled');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});
it('returns false for regular Error', () => {
const error = new Error('Something went wrong');
expect(isAbortError(error)).toBe(false);
});
it('returns false for TypeError', () => {
const error = new TypeError('Network failure');
expect(isAbortError(error)).toBe(false);
});
it('returns false for null', () => {
expect(isAbortError(null)).toBe(false);
});
it('returns false for undefined', () => {
expect(isAbortError(undefined)).toBe(false);
});
it('returns false for non-Error objects', () => {
expect(isAbortError({ message: 'error' })).toBe(false);
expect(isAbortError('error string')).toBe(false);
expect(isAbortError(42)).toBe(false);
});
it('returns false for Error subclasses with different names', () => {
class CustomError extends Error {
constructor() {
super('Custom error');
this.name = 'CustomError';
}
}
expect(isAbortError(new CustomError())).toBe(false);
});
});
describe('fetchJson (via api methods)', () => {
const mockFetch = vi.fn();
// Replace global fetch before each test, restore after
afterEach(() => {
vi.restoreAllMocks();
});
function installMockFetch() {
global.fetch = mockFetch;
}
describe('successful responses', () => {
it('returns parsed JSON on a successful response', async () => {
installMockFetch();
const healthData = {
status: 'connected',
radio_connected: true,
radio_initializing: false,
connection_info: 'Serial: /dev/ttyUSB0',
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(healthData),
});
const result = await api.getHealth();
expect(result).toEqual(healthData);
});
it('calls fetch with /api prefix', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
await api.getContacts();
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts?limit=100&offset=0');
});
it('builds repeater advert path endpoint query', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
await api.getRepeaterAdvertPaths(12);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
});
});
describe('error handling', () => {
it('extracts detail from FastAPI JSON error response', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
text: () => Promise.resolve('{"detail": "Radio not connected"}'),
});
await expect(api.getHealth()).rejects.toThrow('Radio not connected');
});
it('uses raw text when error response is not JSON', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve('Something broke on the server'),
});
await expect(api.getHealth()).rejects.toThrow('Something broke on the server');
});
it('uses statusText when error text is empty', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 502,
statusText: 'Bad Gateway',
text: () => Promise.resolve(''),
});
await expect(api.getHealth()).rejects.toThrow('Bad Gateway');
});
it('uses raw text when JSON lacks detail field', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
text: () => Promise.resolve('{"error": "validation failed"}'),
});
await expect(api.getHealth()).rejects.toThrow('{"error": "validation failed"}');
});
});
describe('Content-Type header', () => {
it('omits Content-Type on GET requests (no body)', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'connected' }),
});
await api.getHealth();
const [, options] = mockFetch.mock.calls[0];
expect(options.headers).not.toHaveProperty('Content-Type');
});
it('sends Content-Type: application/json on POST requests with body', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 1, text: 'hello' }),
});
await api.sendDirectMessage('abc123', 'hello');
const [, options] = mockFetch.mock.calls[0];
expect(options.headers).toEqual(
expect.objectContaining({ 'Content-Type': 'application/json' })
);
});
it('omits Content-Type on POST requests without a body', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
contact: null,
forward_path: { path: '', path_len: 0, path_hash_mode: 0 },
return_path: { path: '', path_len: 0, path_hash_mode: 0 },
}),
});
await api.requestPathDiscovery('aa'.repeat(32));
const [, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
expect(options.headers).not.toHaveProperty('Content-Type');
});
});
describe('HTTP methods and body', () => {
it('sends POST with JSON body for sendDirectMessage', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 1,
type: 'PRIV',
text: 'hello',
destination: 'abc123',
}),
});
await api.sendDirectMessage('abc123', 'hello');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/messages/direct');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({
destination: 'abc123',
text: 'hello',
});
});
it('sends PATCH with JSON body for updateRadioConfig', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ name: 'NewName' }),
});
await api.updateRadioConfig({ name: 'NewName' });
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/config');
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
});
it('sends PUT with JSON body for setPrivateKey', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok' }),
});
await api.setPrivateKey('my-secret-key');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/private-key');
expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
});
it('sends POST with JSON body for mesh discovery', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ target: 'repeaters', duration_seconds: 8, results: [] }),
});
await api.discoverMesh('repeaters');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/discover');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
});
it('sends DELETE for deleteContact', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok' }),
});
await api.deleteContact('pubkey123');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/pubkey123');
expect(options.method).toBe('DELETE');
});
it('sends POST without body for sendAdvertisement', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok' }),
});
await api.sendAdvertisement();
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBeUndefined();
});
});
describe('AbortSignal passthrough', () => {
it('passes signal option through to fetch for getMessages', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
const controller = new AbortController();
await api.getMessages({ limit: 10 }, controller.signal);
const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(controller.signal);
});
it('calls fetch without signal when none is provided', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
await api.getMessages({ limit: 10 });
const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBeUndefined();
});
});
describe('api.getMessages query parameter construction', () => {
it('builds query string with all parameters', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
await api.getMessages({
limit: 50,
offset: 10,
type: 'PRIV',
conversation_key: 'abc123',
before: 1700000000,
before_id: 99,
});
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('/api/messages?');
expect(url).toContain('limit=50');
expect(url).toContain('offset=10');
expect(url).toContain('type=PRIV');
expect(url).toContain('conversation_key=abc123');
expect(url).toContain('before=1700000000');
expect(url).toContain('before_id=99');
});
it('builds URL without query string when no params given', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
await api.getMessages();
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/messages');
});
});
});