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.
778 lines
23 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|