Files
MeshBot/src/mesh/meshmonitor/mesh-monitor-client.spec.ts
T
kamilkrzywda f80340ae6e feat(meshmonitor): add stateless MeshMonitor HTTP-gateway client + fake + module
Phase 1 of the MeshMonitor HTTP-gateway migration: a stateless REST client
that a later phase wires into MeshService to replace the raw
Meshtastic protobuf-over-TCP bridge.

- mesh-monitor-client.ts: MeshMonitorClient interface, HttpMeshMonitorClient
  (native fetch, Bearer auth, injectable base/source/poll params), mappers for
  the gateway's actual JSON shapes (source-scoped paths, flat node position,
  second-based node lastHeard vs ms message/telemetry timestamps, long-format
  telemetry, packet id = 3rd segment of the id string), MeshMonitorError,
  nodeNumToId/nodeIdToNum, shared assertSendTarget XOR, and
  createMeshMonitorClientFromEnv() (null when MESHMONITOR_API_TOKEN unset).
- fake-mesh-monitor-client.ts: in-memory double mirroring the client contract
  (set*/pushInbound config, sends[] recorder, outbound mirrored into
  getMessages, newest-first, limit/channel/toNodeId/fromNodeId filters).
- meshmonitor.module.ts: thin Nest provider exposing the env-built client
  (or null) under MESH_MONITOR_CLIENT for Phase 2's MeshModule.
- specs: HttpMeshMonitorClient against an in-process node:http gateway
  (raw->typed mapping per endpoint, Authorization: Bearer header, send body
  shape with channel/toNodeId XOR + replyId passthrough, 401/413/429/503
  -> MeshMonitorError); FakeMeshMonitorClient behavior.

No MeshService changes, no version bump, no changelog, no PR.
2026-10-12 20:29:50 +00:00

778 lines
23 KiB
TypeScript

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<string, CannedResponse>;
interface Gateway {
baseUrl: string;
requests: RecordedRequest[];
close: () => Promise<void>;
}
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<Gateway> {
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<void>((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,
);
});
});