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.
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { FakeMeshMonitorClient } from './fake-mesh-monitor-client.js';
|
||||
import { BROADCAST_ADDR } from './mesh-monitor-client.js';
|
||||
|
||||
const BOT_NUM = 3994646728; // !ee1978c8
|
||||
const KAMIL_NUM = 2927200947; // !ae798ab3
|
||||
|
||||
describe('FakeMeshMonitorClient', () => {
|
||||
let fake: FakeMeshMonitorClient;
|
||||
|
||||
beforeEach(() => {
|
||||
fake = new FakeMeshMonitorClient({ localNum: BOT_NUM });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// status
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns a default status until configured', async () => {
|
||||
expect(await fake.getStatus()).toEqual({
|
||||
connected: true,
|
||||
myNodeNum: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the configured status (defensively copied)', async () => {
|
||||
fake.setStatus({ connected: false, myNodeNum: BOT_NUM, longName: 'Bot' });
|
||||
const s = await fake.getStatus();
|
||||
s.connected = true; // mutate the returned copy
|
||||
expect((await fake.getStatus()).connected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// nodes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('getNodes / getNode', () => {
|
||||
beforeEach(() => {
|
||||
fake.setNodes([
|
||||
{
|
||||
num: BOT_NUM,
|
||||
longName: 'SP8MK Bot',
|
||||
shortName: '8MKb',
|
||||
position: { lat: 51, lon: 22 },
|
||||
},
|
||||
{ num: KAMIL_NUM, longName: 'Kamil', shortName: 'KK' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all configured nodes', async () => {
|
||||
expect(await fake.getNodes()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('finds a node by number', async () => {
|
||||
expect((await fake.getNode(KAMIL_NUM))?.longName).toBe('Kamil');
|
||||
});
|
||||
|
||||
it('finds a node by !hex id', async () => {
|
||||
expect((await fake.getNode('!ae798ab3'))?.longName).toBe('Kamil');
|
||||
expect((await fake.getNode('!ee1978c8'))?.longName).toBe('SP8MK Bot');
|
||||
});
|
||||
|
||||
it('finds a node by numeric string', async () => {
|
||||
expect((await fake.getNode(String(KAMIL_NUM)))?.longName).toBe('Kamil');
|
||||
});
|
||||
|
||||
it('finds a node by name (case-insensitive)', async () => {
|
||||
expect((await fake.getNode('kamil'))?.num).toBe(KAMIL_NUM);
|
||||
expect((await fake.getNode('8mkB'))?.num).toBe(BOT_NUM);
|
||||
});
|
||||
|
||||
it('returns null for an unknown node', async () => {
|
||||
expect(await fake.getNode(999)).toBeNull();
|
||||
expect(await fake.getNode('!deadbeef')).toBeNull();
|
||||
expect(await fake.getNode('nobody')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// channels + telemetry (passthrough of configured data)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('getChannels / getTelemetry', () => {
|
||||
it('returns configured channels and telemetry', async () => {
|
||||
fake.setChannels([
|
||||
{ index: 0, name: '', psk: 'REDACTED' },
|
||||
{ index: 3, name: 'test' },
|
||||
]);
|
||||
fake.setTelemetry([
|
||||
{ fromNum: BOT_NUM, at: 1, kind: 'airUtilTx', data: { value: 0.1 } },
|
||||
]);
|
||||
expect(await fake.getChannels()).toHaveLength(2);
|
||||
expect((await fake.getTelemetry())[0].kind).toBe('airUtilTx');
|
||||
});
|
||||
|
||||
it('returns empty arrays by default', async () => {
|
||||
expect(await fake.getChannels()).toEqual([]);
|
||||
expect(await fake.getTelemetry()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// pushInbound + getMessages
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('getMessages', () => {
|
||||
it('returns inbound messages newest first', async () => {
|
||||
fake.pushInbound({ packetId: 1, text: 'first', channel: 0, at: 100 });
|
||||
fake.pushInbound({ packetId: 2, text: 'second', channel: 0, at: 200 });
|
||||
const msgs = await fake.getMessages();
|
||||
expect(msgs.map((m) => m.text)).toEqual(['second', 'first']);
|
||||
});
|
||||
|
||||
it('filters by channel', async () => {
|
||||
fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 });
|
||||
fake.pushInbound({ packetId: 2, text: 'b', channel: 3, at: 2 });
|
||||
const msgs = await fake.getMessages({ channel: 3 });
|
||||
expect(msgs.map((m) => m.text)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('filters by toNodeId and fromNodeId', async () => {
|
||||
fake.pushInbound({
|
||||
packetId: 1,
|
||||
fromId: '!ee1978c8',
|
||||
toId: '!ffffffff',
|
||||
text: 'bcast',
|
||||
channel: 3,
|
||||
at: 1,
|
||||
});
|
||||
fake.pushInbound({
|
||||
packetId: 2,
|
||||
fromId: '!ae798ab3',
|
||||
toId: '!ee1978c8',
|
||||
text: 'dm',
|
||||
channel: -1,
|
||||
at: 2,
|
||||
});
|
||||
expect(
|
||||
(await fake.getMessages({ toNodeId: '!ffffffff' })).map((m) => m.text),
|
||||
).toEqual(['bcast']);
|
||||
expect(
|
||||
(await fake.getMessages({ fromNodeId: '!ae798ab3' })).map(
|
||||
(m) => m.text,
|
||||
),
|
||||
).toEqual(['dm']);
|
||||
});
|
||||
|
||||
it('applies limit (newest N) after filtering', async () => {
|
||||
fake.pushInbound({ packetId: 1, text: 'a', channel: 0, at: 1 });
|
||||
fake.pushInbound({ packetId: 2, text: 'b', channel: 0, at: 2 });
|
||||
fake.pushInbound({ packetId: 3, text: 'c', channel: 3, at: 3 });
|
||||
// newest-first: c, b, a — limit 2 → c, b
|
||||
expect((await fake.getMessages({ limit: 2 })).map((m) => m.text)).toEqual(
|
||||
['c', 'b'],
|
||||
);
|
||||
// limit + channel: only c is on ch3
|
||||
expect(
|
||||
(await fake.getMessages({ limit: 2, channel: 3 })).map((m) => m.text),
|
||||
).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('returns a defensive copy (mutating the result does not leak)', async () => {
|
||||
fake.pushInbound({ packetId: 1, text: 'x', channel: 0, at: 1 });
|
||||
const m = (await fake.getMessages())[0];
|
||||
m.text = 'mutated';
|
||||
expect((await fake.getMessages())[0].text).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// send
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('send', () => {
|
||||
it('records a directed send and mirrors it as an outbound message', async () => {
|
||||
const out = await fake.send({ text: 'hi kamil', toNodeId: '!ae798ab3' });
|
||||
expect(out.deliveryState).toBe('pending');
|
||||
expect(out.packetId).toBeGreaterThan(0);
|
||||
|
||||
expect(fake.sends).toHaveLength(1);
|
||||
expect(fake.sends[0]).toMatchObject({
|
||||
text: 'hi kamil',
|
||||
toNodeId: '!ae798ab3',
|
||||
packetId: out.packetId,
|
||||
});
|
||||
|
||||
const msgs = await fake.getMessages();
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]).toMatchObject({
|
||||
packetId: out.packetId,
|
||||
fromNum: BOT_NUM,
|
||||
fromId: '!ee1978c8',
|
||||
toNum: KAMIL_NUM,
|
||||
toId: '!ae798ab3',
|
||||
text: 'hi kamil',
|
||||
channel: -1, // DM rides channel -1
|
||||
requestId: out.packetId,
|
||||
deliveryState: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('records a broadcast send with the broadcast destination', async () => {
|
||||
const out = await fake.send({ text: 'hello mesh', channel: 3 });
|
||||
const msgs = await fake.getMessages();
|
||||
expect(msgs[0]).toMatchObject({
|
||||
toNum: BROADCAST_ADDR,
|
||||
toId: '!ffffffff',
|
||||
channel: 3,
|
||||
text: 'hello mesh',
|
||||
});
|
||||
expect(out.packetId).toBe(msgs[0].packetId);
|
||||
});
|
||||
|
||||
it('assigns a distinct, non-zero packet id per send', async () => {
|
||||
const a = await fake.send({ text: 'one', toNodeId: '!ae798ab3' });
|
||||
const b = await fake.send({ text: 'two', channel: 0 });
|
||||
expect(a.packetId).not.toBe(b.packetId);
|
||||
expect(a.packetId).toBeGreaterThan(0);
|
||||
expect(b.packetId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('carries replyId onto the recorded message', async () => {
|
||||
await fake.send({ text: 'replying', toNodeId: '!ae798ab3', replyId: 55 });
|
||||
expect((await fake.getMessages())[0].replyId).toBe(55);
|
||||
expect(fake.sends[0].replyId).toBe(55);
|
||||
});
|
||||
|
||||
it('rejects XOR violations exactly like the HTTP client', async () => {
|
||||
await expect(
|
||||
fake.send({ text: 'x', channel: 3, toNodeId: '!1' }),
|
||||
).rejects.toThrow(/mutually exclusive/);
|
||||
await expect(fake.send({ text: 'x' })).rejects.toThrow(
|
||||
/required \(XOR\)/,
|
||||
);
|
||||
await expect(fake.send({ text: '', channel: 3 })).rejects.toThrow(
|
||||
/non-empty/,
|
||||
);
|
||||
// none of the rejected sends were recorded
|
||||
expect(fake.sends).toHaveLength(0);
|
||||
expect(await fake.getMessages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clearTraffic() resets sends + messages + the packet-id counter', async () => {
|
||||
await fake.send({ text: 'a', channel: 0 });
|
||||
fake.pushInbound({ packetId: 1, text: 'in', channel: 0, at: 1 });
|
||||
fake.clearTraffic();
|
||||
expect(fake.sends).toHaveLength(0);
|
||||
expect(await fake.getMessages()).toHaveLength(0);
|
||||
// counter reset → first id is 1 again
|
||||
expect((await fake.send({ text: 'b', channel: 0 })).packetId).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user