Add more tests and update AGENTS.md

This commit is contained in:
Jack Kingsman
2026-02-12 00:31:52 -08:00
parent b80093ba94
commit 7e7330eb12
8 changed files with 1916 additions and 7 deletions
+287 -2
View File
@@ -2,8 +2,8 @@
* Tests for API utilities.
*/
import { describe, it, expect } from 'vitest';
import { isAbortError } from '../api';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { isAbortError, api } from '../api';
describe('isAbortError', () => {
it('returns true for AbortError', () => {
@@ -60,3 +60,288 @@ describe('isAbortError', () => {
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,
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');
});
});
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('always sends Content-Type: application/json on GET requests', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'connected' }),
});
await api.getHealth();
const [, options] = mockFetch.mock.calls[0];
expect(options.headers).toEqual(
expect.objectContaining({ 'Content-Type': 'application/json' })
);
});
it('always sends Content-Type: application/json on POST requests', 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' })
);
});
});
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 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');
});
});
});
+1
View File
@@ -43,6 +43,7 @@ const baseSettings: AppSettings = {
last_message_times: {},
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
bots: [],
};
+1
View File
@@ -131,6 +131,7 @@ export interface AppSettings {
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
last_advert_time: number;
bots: BotConfig[];
}