Message history with delivery tracking (GET /api/messages) (#8)
CI / build + unit (push) Successful in 32s

This commit was merged in pull request #8.
This commit is contained in:
2026-09-24 16:19:32 +02:00
parent 5292303786
commit 57ad27d66f
8 changed files with 325 additions and 6 deletions
+175
View File
@@ -81,6 +81,63 @@ function positionPacketFrame(
}
const KAMIL_NUM = 123456789;
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
// ACK/NACK for our sends: ROUTING_APP packet with Data.requestId = original id and a serialized Routing in the payload
function routingFrame(
requestId: number,
errorReason: P.Mesh.Routing_Error,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.ROUTING_APP,
requestId,
payload: toBinary(
P.Mesh.RoutingSchema,
create(P.Mesh.RoutingSchema, {
variant: { case: 'errorReason', value: errorReason },
}),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from: 1,
id: 2,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
function kamilNodeInfo(): Partial<P.Mesh.NodeInfo> {
return {
user: {
@@ -437,3 +494,121 @@ describe('MeshService node database', () => {
}
});
});
describe('MeshService message history', () => {
it('captures inbound text as rx records', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
await waitFor(() => svc.messages().length === 1);
expect(svc.messages()).toEqual([
{
id: 1,
dir: 'rx',
from: KAMIL_NUM,
text: 'ping',
channel: 2,
at: expect.any(Number),
},
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('tracks a tx send pending -> delivered on the routing ACK', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const id = await svc.sendText(42, 'hello testowy');
expect(svc.messages()[0]).toMatchObject({
id,
dir: 'tx',
to: 42,
text: 'hello testowy',
channel: 2,
status: 'pending',
});
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE));
await waitFor(() => svc.messages()[0]?.status === 'delivered');
expect(svc.messages()[0]).toMatchObject({
id,
dir: 'tx',
status: 'delivered',
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('marks a tx failed with the routing error reason on NACK', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const id = await svc.sendText(42, 'will fail');
expect(svc.messages()[0]?.status).toBe('pending');
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.TIMEOUT));
await waitFor(() => svc.messages()[0]?.status === 'failed');
expect(svc.messages()[0]).toMatchObject({
id,
status: 'failed',
ackError: 'TIMEOUT', // human-readable Routing.Error name
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('caps history at 100 records, dropping the oldest', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
for (let i = 0; i < 105; i++)
bridge.sockets[0].write(textPacketFrame(42, KAMIL_NUM, `m-${i}`));
await waitFor(() => svc.messages().length === 100, 10000);
expect(svc.messages()[0]?.text).toBe('m-104'); // newest first
expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});