import * as http from 'node:http'; import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BROADCAST_ADDR, HttpMeshMonitorClient, MeshMonitorError, createMeshMonitorClientFromEnv, nodeIdToNum, nodeNumToId, } from './mesh-monitor-client.js'; /** Test-only bearer token — never the real one (which lives only in .env). */ const TOKEN = 'mm_v1_test_token_not_real'; const SOURCE = 'srctest'; /** The gateway serves under `/api/v1`; the client's baseUrl carries it. */ const BASE = '/api/v1'; interface RecordedRequest { method: string; url: string; pathname: string; query?: string; headers: http.IncomingHttpHeaders; body: string; } interface CannedResponse { status?: number; body: unknown; } type Routes = Record; interface Gateway { baseUrl: string; requests: RecordedRequest[]; close: () => Promise; } function splitQuery(url: string): [string, string | undefined] { const i = url.indexOf('?'); return i === -1 ? [url, undefined] : [url.slice(0, i), url.slice(i + 1)]; } /** * In-process `node:http` stand-in for the MeshMonitor gateway. Routes are keyed * by `METHOD pathname` (query stripped). Every request is recorded so tests can * assert the exact path, headers and body the client emitted. */ function startGateway(routes: Routes): Promise { const requests: RecordedRequest[] = []; const server = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const url = req.url ?? '/'; const [pathname, query] = splitQuery(url); requests.push({ method: req.method ?? 'GET', url, pathname, query, headers: req.headers, body, }); const route = routes[`${req.method} ${pathname}`]; const status = route ? (route.status ?? 200) : 404; res.writeHead(status, { 'content-type': 'application/json' }); res.end( JSON.stringify( route ? route.body : { success: false, error: `no route ${req.method} ${pathname}` }, ), ); }); }); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const port = (server.address() as AddressInfo).port; resolve({ baseUrl: `http://127.0.0.1:${port}${BASE}`, requests, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }), }); }); }); } /** Route key for a source-scoped path, e.g. route('GET', '/status'). */ const route = (method: string, path: string): string => `${method} ${BASE}/sources/${SOURCE}${path}`; let gw: Gateway | null = null; beforeEach(() => { gw = null; }); afterEach(async () => { await gw?.close(); gw = null; for (const k of [ 'MESHMONITOR_API_TOKEN', 'MESHMONITOR_API_BASE', 'MESHMONITOR_SOURCE', 'MESHMONITOR_TIMEOUT_MS', ]) delete process.env[k]; }); function makeClient(baseUrl: string): HttpMeshMonitorClient { return new HttpMeshMonitorClient({ baseUrl, sourceId: SOURCE, token: TOKEN, timeoutMs: 5000, }); } // --------------------------------------------------------------------------- // Helpers: node id <-> number, broadcast address // --------------------------------------------------------------------------- describe('nodeNumToId / nodeIdToNum', () => { it('maps a uint32 node number to an 8-char !hex id', () => { expect(nodeNumToId(3994646728)).toBe('!ee1978c8'); expect(nodeNumToId(2927200947)).toBe('!ae798ab3'); expect(nodeNumToId(0)).toBe('!00000000'); expect(nodeNumToId(4294967295)).toBe('!ffffffff'); }); it('parses an !hex id back to a number (round-trip)', () => { expect(nodeIdToNum('!ee1978c8')).toBe(3994646728); expect(nodeIdToNum('!AE798AB3')).toBe(2927200947); // case-insensitive expect(nodeIdToNum('!ffffffff')).toBe(4294967295); for (const n of [1, 255, 0xdeadbeef, 4294967295]) expect(nodeIdToNum(nodeNumToId(n))).toBe(n); }); it('rejects malformed ids with null', () => { expect(nodeIdToNum('ee1978c8')).toBeNull(); expect(nodeIdToNum('!zzzz')).toBeNull(); expect(nodeIdToNum('!1234567890')).toBeNull(); // > 8 hex digits expect(nodeIdToNum('')).toBeNull(); }); it('exposes the broadcast address as 0xffffffff / !ffffffff', () => { expect(BROADCAST_ADDR).toBe(4294967295); expect(nodeNumToId(BROADCAST_ADDR)).toBe('!ffffffff'); }); }); // --------------------------------------------------------------------------- // getStatus // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient.getStatus', () => { it('GETs the source status, sends the Bearer token, maps raw → MMStatus', async () => { gw = await startGateway({ [route('GET', '/status')]: { body: { success: true, data: { localNodeNum: 3994646728, localNodeId: '!ee1978c8', longName: 'SP8MK Bot', shortName: '8MKb', connected: true, nodeResponsive: true, }, }, }, }); const s = await makeClient(gw.baseUrl).getStatus(); expect(s).toEqual({ connected: true, myNodeNum: 3994646728, longName: 'SP8MK Bot', nodeResponsive: true, }); const req = gw.requests[0]; expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/status`); expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`); }); it('defaults connected=false / myNodeNum=0 when fields are absent', async () => { gw = await startGateway({ [route('GET', '/status')]: { body: { success: true, data: {} } }, }); const s = await makeClient(gw.baseUrl).getStatus(); expect(s.connected).toBe(false); expect(s.myNodeNum).toBe(0); expect(s.longName).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // getNodes // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient.getNodes', () => { it('GETs with active/sinceDays, maps flat position + second-based lastHeard', async () => { gw = await startGateway({ [route('GET', '/nodes')]: { body: { success: true, count: 2, data: [ { nodeNum: 3994646728, nodeId: '!ee1978c8', longName: 'SP8MK Bot', shortName: '8MKb', hwModel: 63, batteryLevel: 37, voltage: 3.607, firmwareVersion: '2.7.15.567b8ea', rssi: null, snr: 0, lastHeard: 1791834334, latitude: 51.253837, longitude: 22.528193, altitude: null, hopsAway: null, viaMqtt: false, }, { nodeNum: 2927200947, nodeId: '!ae798ab3', longName: 'Lubmesh Node', shortName: 'LM', hwModel: 14, batteryLevel: 88, lastHeard: 0, latitude: null, longitude: null, snr: -8.25, rssi: -118, viaMqtt: false, }, ], }, }, }); const nodes = await makeClient(gw.baseUrl).getNodes(); expect(nodes).toEqual([ { num: 3994646728, longName: 'SP8MK Bot', shortName: '8MKb', hwModel: 63, batteryLevel: 37, voltage: 3.607, firmwareVersion: '2.7.15.567b8ea', snr: 0, lastHeard: 1791834334, position: { lat: 51.253837, lon: 22.528193 }, }, { num: 2927200947, longName: 'Lubmesh Node', shortName: 'LM', hwModel: 14, batteryLevel: 88, snr: -8.25, rssi: -118, }, ]); const req = gw.requests[0]; expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/nodes`); expect(req.query).toBe('active=true&sinceDays=7'); expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`); }); }); // --------------------------------------------------------------------------- // getNode // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient.getNode', () => { it('resolves a numeric argument to !hex and maps the node detail', async () => { gw = await startGateway({ [route('GET', '/nodes/!ee1978c8')]: { body: { success: true, data: { nodeNum: 3994646728, nodeId: '!ee1978c8', longName: 'SP8MK Bot', batteryLevel: 37, lastHeard: 1791834334, latitude: 51.253837, longitude: 22.528193, altitude: null, viaMqtt: false, }, }, }, }); const n = await makeClient(gw.baseUrl).getNode(3994646728); expect(n?.num).toBe(3994646728); expect(n?.longName).toBe('SP8MK Bot'); expect(n?.position).toEqual({ lat: 51.253837, lon: 22.528193 }); expect(gw.requests[0].pathname).toBe( `${BASE}/sources/${SOURCE}/nodes/!ee1978c8`, ); }); it('passes an explicit !hex id through unchanged', async () => { gw = await startGateway({ [route('GET', '/nodes/!ae798ab3')]: { body: { success: true, data: { nodeNum: 2927200947, longName: 'X' }, }, }, }); const n = await makeClient(gw.baseUrl).getNode('!ae798ab3'); expect(n?.num).toBe(2927200947); expect(gw.requests[0].pathname).toBe( `${BASE}/sources/${SOURCE}/nodes/!ae798ab3`, ); }); it('returns null on a 404 (unknown node) instead of throwing', async () => { gw = await startGateway({ [route('GET', '/nodes/!00000001')]: { status: 404, body: { success: false, error: 'node not found' }, }, }); await expect(makeClient(gw.baseUrl).getNode(1)).resolves.toBeNull(); }); it('re-throws non-404 errors (e.g. 500)', async () => { gw = await startGateway({ [route('GET', '/nodes/!ee1978c8')]: { status: 500, body: { success: false, error: 'boom' }, }, }); await expect( makeClient(gw.baseUrl).getNode(3994646728), ).rejects.toMatchObject({ name: 'MeshMonitorError', status: 500 }); }); }); // --------------------------------------------------------------------------- // getChannels // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient.getChannels', () => { it('sorts by index and redacts the psk (marker when set, omitted when null)', async () => { gw = await startGateway({ [route('GET', '/channels')]: { body: { success: true, count: 3, data: [ { id: 0, name: '', displayName: 'Primary', uplinkEnabled: true, downlinkEnabled: false, pskSet: true, psk: 'AQ==', }, { id: 2, name: 'testowy', displayName: 'testowy', uplinkEnabled: false, downlinkEnabled: false, pskSet: false, psk: null, }, { id: 1, name: 'LubMesh', displayName: 'LubMesh', uplinkEnabled: true, downlinkEnabled: false, pskSet: true, psk: 'LQ==', }, ], }, }, }); const ch = await makeClient(gw.baseUrl).getChannels(); expect(ch.map((c) => c.index)).toEqual([0, 1, 2]); expect(ch).toEqual([ { index: 0, name: '', psk: 'REDACTED', uplinkEnabled: true, downlinkEnabled: false, }, { index: 1, name: 'LubMesh', psk: 'REDACTED', uplinkEnabled: true, downlinkEnabled: false, }, { index: 2, name: 'testowy', uplinkEnabled: false, downlinkEnabled: false, }, ]); // the raw base64 key must never leak into the mapped output expect(JSON.stringify(ch)).not.toContain('AQ=='); expect(JSON.stringify(ch)).not.toContain('LQ=='); }); }); // --------------------------------------------------------------------------- // getTelemetry // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient.getTelemetry', () => { it('maps long-format rows to { fromNum, at(ms), kind, data }', async () => { gw = await startGateway({ [route('GET', '/telemetry')]: { body: { success: true, count: 3, data: [ { nodeNum: 3994646728, telemetryType: 'airUtilTx', timestamp: 1791834214540, value: 0.1517, unit: '%', packetId: 3971503092, channel: null, gpsAccuracy: null, rxSnr: null, }, { nodeNum: 4059237616, telemetryType: 'altitude', timestamp: 1791834193282, value: 185, unit: 'm', packetId: 3637205748, channel: 0, gpsAccuracy: null, rxSnr: null, }, { nodeNum: 1475656844, telemetryType: 'linkQuality', timestamp: 1791834161250, value: 9, unit: 'quality', packetId: null, channel: null, gpsAccuracy: null, rxSnr: null, }, ], }, }, }); const t = await makeClient(gw.baseUrl).getTelemetry(); expect(t).toEqual([ { fromNum: 3994646728, at: 1791834214540, kind: 'airUtilTx', data: { value: 0.1517, unit: '%', packetId: 3971503092 }, }, { fromNum: 4059237616, at: 1791834193282, kind: 'altitude', data: { value: 185, unit: 'm', packetId: 3637205748, channel: 0 }, }, { fromNum: 1475656844, at: 1791834161250, kind: 'linkQuality', data: { value: 9, unit: 'quality' }, }, ]); }); }); // --------------------------------------------------------------------------- // getMessages // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient.getMessages', () => { it('maps messages (packetId from the id string, ms timestamps), newest first', async () => { gw = await startGateway({ [route('GET', '/messages')]: { body: { success: true, count: 2, data: [ { id: 'src_3994646728_268035341', fromNodeNum: 3994646728, fromNodeId: '!ee1978c8', toNodeNum: 4294967295, toNodeId: 'broadcast', text: 'hi ch3', channel: 3, portnum: 1, requestId: 268035341, timestamp: 1791832779000, replyId: 1807244554, rxSnr: null, viaMqtt: null, deliveryState: 'delivered', }, { id: 'src_2927200947_3264248582', fromNodeNum: 2927200947, fromNodeId: '!ae798ab3', toNodeNum: 4294967295, toNodeId: '!ffffffff', text: 'weather', channel: 1, portnum: 1, requestId: null, timestamp: 1791828092137, replyId: null, rxSnr: -7.5, viaMqtt: false, deliveryState: null, }, ], }, }, }); const msgs = await makeClient(gw.baseUrl).getMessages(); expect(msgs).toEqual([ { packetId: 268035341, fromNum: 3994646728, fromId: '!ee1978c8', toNum: 4294967295, toId: 'broadcast', text: 'hi ch3', channel: 3, portnum: 1, requestId: 268035341, replyId: 1807244554, at: 1791832779000, deliveryState: 'delivered', }, { packetId: 3264248582, fromNum: 2927200947, fromId: '!ae798ab3', toNum: 4294967295, toId: '!ffffffff', text: 'weather', channel: 1, portnum: 1, at: 1791828092137, snr: -7.5, }, ]); }); it('encodes the limit/channel/toNodeId/fromNodeId filters into the query', async () => { gw = await startGateway({ [route('GET', '/messages')]: { body: { success: true, data: [] } }, }); await makeClient(gw.baseUrl).getMessages({ limit: 10, channel: 3, toNodeId: '!ffffffff', fromNodeId: '!ae798ab3', }); // URLSearchParams percent-encodes `!` as %21 — assert on the decoded values const q = new URLSearchParams(gw.requests[0].query); expect(q.get('limit')).toBe('10'); expect(q.get('channel')).toBe('3'); expect(q.get('toNodeId')).toBe('!ffffffff'); expect(q.get('fromNodeId')).toBe('!ae798ab3'); }); }); // --------------------------------------------------------------------------- // send // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient.send', () => { it('sends a DM: POSTs { text, toNodeId, replyId } and returns the packet id', async () => { gw = await startGateway({ [route('POST', '/messages')]: { body: { success: true, data: { messageId: 'mid-1', requestId: 42, deliveryState: 'pending' }, }, }, }); const out = await makeClient(gw.baseUrl).send({ text: 'hello', toNodeId: '!ae798ab3', replyId: 7, }); expect(out).toEqual({ packetId: 42, deliveryState: 'pending', messageId: 'mid-1', }); const req = gw.requests[0]; expect(req.pathname).toBe(`${BASE}/sources/${SOURCE}/messages`); expect(req.headers['content-type']).toBe('application/json'); expect(req.headers.authorization).toBe(`Bearer ${TOKEN}`); expect(JSON.parse(req.body)).toEqual({ text: 'hello', toNodeId: '!ae798ab3', replyId: 7, }); }); it('sends a broadcast: POSTs { text, channel } (no toNodeId)', async () => { gw = await startGateway({ [route('POST', '/messages')]: { body: { success: true, data: { messageId: 'mid-2', requestId: 99, deliveryState: 'pending' }, }, }, }); const out = await makeClient(gw.baseUrl).send({ text: 'hi all', channel: 3, }); expect(out.packetId).toBe(99); const body = JSON.parse(gw.requests[0].body); expect(body).toEqual({ text: 'hi all', channel: 3 }); expect(body).not.toHaveProperty('toNodeId'); expect(body).not.toHaveProperty('replyId'); }); it('omits replyId when not provided', async () => { gw = await startGateway({ [route('POST', '/messages')]: { body: { success: true, data: { requestId: 5, deliveryState: 'pending' }, }, }, }); await makeClient(gw.baseUrl).send({ text: 'x', toNodeId: '!ee1978c8' }); const body = JSON.parse(gw.requests[0].body); expect(body).toEqual({ text: 'x', toNodeId: '!ee1978c8' }); expect(body).not.toHaveProperty('replyId'); }); it('defaults deliveryState to pending when the response omits it', async () => { gw = await startGateway({ [route('POST', '/messages')]: { body: { success: true, data: { requestId: 3 } }, }, }); const out = await makeClient(gw.baseUrl).send({ text: 'y', channel: 0 }); expect(out.deliveryState).toBe('pending'); expect(out.packetId).toBe(3); expect(out.messageId).toBeUndefined(); }); it('rejects a send that sets BOTH channel and toNodeId (XOR) before any request', async () => { gw = await startGateway({ [route('POST', '/messages')]: { body: { success: true } }, }); await expect( makeClient(gw.baseUrl).send({ text: 'x', channel: 3, toNodeId: '!1' }), ).rejects.toThrow(/mutually exclusive/); expect(gw.requests).toHaveLength(0); }); it('rejects a send with NEITHER channel nor toNodeId (XOR) before any request', async () => { gw = await startGateway({ [route('POST', '/messages')]: { body: { success: true } }, }); await expect(makeClient(gw.baseUrl).send({ text: 'x' })).rejects.toThrow( /required \(XOR\)/, ); expect(gw.requests).toHaveLength(0); }); it('rejects an empty text before any request', async () => { gw = await startGateway({ [route('POST', '/messages')]: { body: { success: true } }, }); await expect( makeClient(gw.baseUrl).send({ text: '', channel: 3 }), ).rejects.toThrow(/non-empty/); expect(gw.requests).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // Error handling (non-2xx → MeshMonitorError with the HTTP status) // --------------------------------------------------------------------------- describe('HttpMeshMonitorClient error handling', () => { for (const status of [401, 413, 429, 503]) { it(`maps HTTP ${status} to a MeshMonitorError carrying the status`, async () => { gw = await startGateway({ [route('GET', '/status')]: { status, body: { success: false, error: `code ${status}` }, }, }); const err = await makeClient(gw.baseUrl) .getStatus() .catch((e) => e); expect(err).toBeInstanceOf(MeshMonitorError); expect(err.status).toBe(status); expect(err.message).toContain(`HTTP ${status}`); }); } it('truncates a long error body to 300 chars', async () => { const longMsg = 'x'.repeat(500); gw = await startGateway({ [route('GET', '/status')]: { status: 500, body: { success: false, error: longMsg }, }, }); const err = await makeClient(gw.baseUrl) .getStatus() .catch((e) => e); expect(err).toBeInstanceOf(MeshMonitorError); expect(err.status).toBe(500); expect((err.body ?? '').length).toBe(300); }); }); // --------------------------------------------------------------------------- // createMeshMonitorClientFromEnv // --------------------------------------------------------------------------- describe('createMeshMonitorClientFromEnv', () => { it('returns null when no token is configured', () => { delete process.env.MESHMONITOR_API_TOKEN; expect(createMeshMonitorClientFromEnv()).toBeNull(); }); it('builds an HttpMeshMonitorClient from env with default base + source', () => { process.env.MESHMONITOR_API_TOKEN = TOKEN; const c = createMeshMonitorClientFromEnv(); expect(c).toBeInstanceOf(HttpMeshMonitorClient); }); it('honours MESHMONITOR_API_BASE and MESHMONITOR_SOURCE overrides without throwing', () => { process.env.MESHMONITOR_API_TOKEN = TOKEN; process.env.MESHMONITOR_API_BASE = 'http://example.org/base/'; process.env.MESHMONITOR_SOURCE = 'custom-src'; expect(createMeshMonitorClientFromEnv()).toBeInstanceOf( HttpMeshMonitorClient, ); }); });