Files
Remote-Terminal-for-MeshCore/tests/e2e/helpers/api.ts
2026-03-23 15:16:04 -07:00

343 lines
8.5 KiB
TypeScript

/**
* Direct REST API helpers for E2E test setup and teardown.
* These bypass the UI to set up preconditions and verify backend state.
*/
const BASE_URL = 'http://localhost:8001/api';
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`API ${init?.method || 'GET'} ${path} returned ${res.status}: ${body}`);
}
return res.json() as Promise<T>;
}
// --- Health ---
export interface HealthStatus {
radio_connected: boolean;
radio_initializing: boolean;
connection_info: string | null;
bots_disabled?: boolean;
bots_disabled_source?: 'env' | 'until_restart' | null;
basic_auth_enabled?: boolean;
}
export function getHealth(): Promise<HealthStatus> {
return fetchJson('/health');
}
// --- Radio Config ---
export interface RadioConfig {
name: string;
public_key: string;
lat: number;
lon: number;
tx_power: number;
freq: number;
bw: number;
sf: number;
cr: number;
}
export type RadioAdvertMode = 'flood' | 'zero_hop';
export function getRadioConfig(): Promise<RadioConfig> {
return fetchJson('/radio/config');
}
export function updateRadioConfig(patch: Partial<RadioConfig>): Promise<RadioConfig> {
return fetchJson('/radio/config', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export function rebootRadio(): Promise<{ status: string; message: string }> {
return fetchJson('/radio/reboot', { method: 'POST' });
}
export function sendAdvertisement(mode: RadioAdvertMode = 'flood'): Promise<{ status: string }> {
return fetchJson('/radio/advertise', {
method: 'POST',
body: JSON.stringify({ mode }),
});
}
// --- Channels ---
export interface Channel {
key: string;
name: string;
is_hashtag: boolean;
on_radio: boolean;
flood_scope_override?: string | null;
}
export function getChannels(): Promise<Channel[]> {
return fetchJson('/channels');
}
export function createChannel(name: string): Promise<Channel> {
return fetchJson('/channels', {
method: 'POST',
body: JSON.stringify({ name }),
});
}
export function deleteChannel(key: string): Promise<void> {
return fetchJson(`/channels/${key}`, { method: 'DELETE' });
}
// --- Contacts ---
export interface Contact {
public_key: string;
name: string | null;
type: number;
flags: number;
direct_path: string | null;
direct_path_len: number;
direct_path_hash_mode: number;
route_override_path?: string | null;
route_override_len?: number | null;
route_override_hash_mode?: number | null;
last_advert: number | null;
lat: number | null;
lon: number | null;
last_seen: number | null;
on_radio: boolean;
last_contacted: number | null;
last_read_at: number | null;
}
export function getContacts(limit: number = 100, offset: number = 0): Promise<Contact[]> {
return fetchJson(`/contacts?limit=${limit}&offset=${offset}`);
}
export function createContact(
publicKey: string,
name?: string,
tryHistorical: boolean = false
): Promise<Contact> {
return fetchJson('/contacts', {
method: 'POST',
body: JSON.stringify({
public_key: publicKey,
...(name ? { name } : {}),
try_historical: tryHistorical,
}),
});
}
export function deleteContact(publicKey: string): Promise<{ status: string }> {
return fetchJson(`/contacts/${publicKey}`, { method: 'DELETE' });
}
export async function getContactByKey(publicKey: string): Promise<Contact | undefined> {
const normalized = publicKey.toLowerCase();
const contacts = await getContacts(500, 0);
return contacts.find((contact) => contact.public_key.toLowerCase() === normalized);
}
export function setContactRoutingOverride(
publicKey: string,
route: string
): Promise<{ status: string; public_key: string }> {
return fetchJson(`/contacts/${publicKey}/routing-override`, {
method: 'POST',
body: JSON.stringify({ route }),
});
}
// --- Messages ---
export interface MessagePath {
path: string;
received_at: number;
}
export interface Message {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string;
text: string;
outgoing: boolean;
acked: number;
received_at: number;
sender_timestamp: number | null;
paths: MessagePath[] | null;
}
export function getMessages(params: {
type?: string;
conversation_key?: string;
limit?: number;
}): Promise<Message[]> {
const qs = new URLSearchParams();
if (params.type) qs.set('type', params.type);
if (params.conversation_key) qs.set('conversation_key', params.conversation_key);
if (params.limit) qs.set('limit', String(params.limit));
return fetchJson(`/messages?${qs}`);
}
export function sendChannelMessage(
channelKey: string,
text: string
): Promise<{ status: string; message_id: number }> {
return fetchJson('/messages/channel', {
method: 'POST',
body: JSON.stringify({ channel_key: channelKey, text }),
});
}
// --- Read state ---
export interface UnreadCounts {
counts: Record<string, number>;
mentions: Record<string, boolean>;
last_message_times: Record<string, number>;
last_read_ats: Record<string, number | null>;
}
export function getUnreads(): Promise<UnreadCounts> {
return fetchJson('/read-state/unreads');
}
export function markAllRead(): Promise<{ status: string; timestamp: number }> {
return fetchJson('/read-state/mark-all-read', { method: 'POST' });
}
// --- Settings ---
export type Favorite = { type: string; id: string };
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: string;
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
}
export function getSettings(): Promise<AppSettings> {
return fetchJson('/settings');
}
export function updateSettings(patch: Partial<AppSettings>): Promise<AppSettings> {
return fetchJson('/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// --- Fanout ---
export interface FanoutConfig {
id: string;
type: string;
name: string;
enabled: boolean;
config: Record<string, unknown>;
scope: Record<string, unknown>;
sort_order: number;
created_at: number;
}
export function getFanoutConfigs(): Promise<FanoutConfig[]> {
return fetchJson('/fanout');
}
export function createFanoutConfig(body: {
type: string;
name: string;
config: Record<string, unknown>;
scope?: Record<string, unknown>;
enabled?: boolean;
}): Promise<FanoutConfig> {
return fetchJson('/fanout', {
method: 'POST',
body: JSON.stringify(body),
});
}
export function updateFanoutConfig(
id: string,
patch: Partial<{ name: string; config: Record<string, unknown>; scope: Record<string, unknown>; enabled: boolean }>
): Promise<FanoutConfig> {
return fetchJson(`/fanout/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export function deleteFanoutConfig(id: string): Promise<{ deleted: boolean }> {
return fetchJson(`/fanout/${id}`, { method: 'DELETE' });
}
// --- Helpers ---
/**
* Ensure #flightless channel exists, creating it if needed.
* Returns the channel object.
*/
export async function ensureFlightlessChannel(): Promise<Channel> {
const channels = await getChannels();
const existing = channels.find((c) => c.name === '#flightless');
if (existing) return existing;
return createChannel('#flightless');
}
/**
* Wait for health to show a fully ready radio, polling with retries.
*/
export async function waitForRadioConnected(
timeoutMs: number = 30_000,
intervalMs: number = 2000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const health = await getHealth();
if (health.radio_connected && !health.radio_initializing) return;
} catch {
// Backend might be restarting
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Radio did not finish reconnect/setup within ${timeoutMs}ms`);
}
// --- Packets / Historical decryption ---
export function getUndecryptedCount(): Promise<{ count: number }> {
return fetchJson('/packets/undecrypted/count');
}
export interface DecryptResult {
started: boolean;
total_packets: number;
message: string;
}
export function decryptHistorical(params: {
key_type: 'channel' | 'contact';
channel_key?: string;
channel_name?: string;
private_key?: string;
contact_public_key?: string;
}): Promise<DecryptResult> {
return fetchJson('/packets/decrypt/historical', {
method: 'POST',
body: JSON.stringify(params),
});
}