mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-29 10:50:17 +00:00
Channels with garbage-decrypted names (pre-#197 data still in DB) are now filtered at the API level using the same non-printable character heuristic from #197. Applied in both Node.js server.js and Go server (store.go, db.go). No data is deleted — only filtered from API responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1262 lines
56 KiB
JavaScript
1262 lines
56 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
// Server route integration tests via supertest
|
||
process.env.NODE_ENV = 'test';
|
||
process.env.SEED_DB = 'true'; // Seed test data
|
||
|
||
const request = require('supertest');
|
||
const { app, server, wss, pktStore, db, cache, lastPathSeenMap, hopPrefixToKey, ambiguousHopPrefixes, resolveUniquePrefixMatch } = require('./server');
|
||
|
||
let passed = 0, failed = 0;
|
||
|
||
async function t(name, fn) {
|
||
try {
|
||
await fn();
|
||
passed++;
|
||
} catch (e) {
|
||
failed++;
|
||
console.error(`FAIL: ${name} — ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
|
||
|
||
// Seed additional test data for branch coverage
|
||
function seedTestData() {
|
||
const now = new Date().toISOString();
|
||
const yesterday = new Date(Date.now() - 86400000).toISOString();
|
||
|
||
// Add nodes with various roles and locations
|
||
const nodes = [
|
||
{ public_key: 'aabb' + '0'.repeat(60), name: 'TestRepeater1', role: 'repeater', lat: 37.7749, lon: -122.4194, last_seen: now, first_seen: yesterday },
|
||
{ public_key: 'ccdd' + '0'.repeat(60), name: 'TestRoom1', role: 'room', lat: 40.7128, lon: -74.0060, last_seen: now, first_seen: yesterday },
|
||
{ public_key: 'eeff' + '0'.repeat(60), name: 'TestCompanion1', role: 'companion', lat: 0, lon: 0, last_seen: yesterday, first_seen: yesterday },
|
||
{ public_key: '1122' + '0'.repeat(60), name: 'TestSensor1', role: 'sensor', lat: 51.5074, lon: -0.1278, last_seen: now, first_seen: yesterday },
|
||
// Node with same 2-char prefix as TestRepeater1 to test ambiguous resolution
|
||
{ public_key: 'aabb' + '1'.repeat(60), name: 'TestRepeater2', role: 'repeater', lat: 34.0522, lon: -118.2437, last_seen: now, first_seen: yesterday },
|
||
];
|
||
for (const n of nodes) {
|
||
try { db.upsertNode(n); } catch {}
|
||
}
|
||
|
||
// Add observer
|
||
try { db.upsertObserver({ id: 'test-obs-1', name: 'TestObs', iata: 'SFO', last_seen: now, first_seen: yesterday }); } catch {}
|
||
try { db.upsertObserver({ id: 'test-obs-2', name: 'TestObs2', iata: 'NYC', last_seen: now, first_seen: yesterday }); } catch {}
|
||
|
||
// Add packets with paths and decoded data
|
||
const packets = [
|
||
{
|
||
raw_hex: '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172',
|
||
timestamp: now, observer_id: 'test-obs-1', snr: 10.5, rssi: -85,
|
||
hash: 'test-hash-001', route_type: 1, payload_type: 4, payload_version: 1,
|
||
path_json: JSON.stringify(['aabb', 'ccdd']),
|
||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'TestRepeater1', pubKey: 'aabb' + '0'.repeat(60), role: 'repeater', lat: 37.7749, lon: -122.4194, flags: { repeater: true } }),
|
||
},
|
||
{
|
||
raw_hex: '2233445566778899AABBCCDD',
|
||
timestamp: yesterday, observer_id: 'test-obs-1', snr: -5, rssi: -110,
|
||
hash: 'test-hash-002', route_type: 0, payload_type: 5, payload_version: 1,
|
||
path_json: JSON.stringify(['aabb', 'ccdd', 'eeff']),
|
||
decoded_json: JSON.stringify({ type: 'TXT_MSG', text: 'Hello test', channelHash: 'ch01', channel_hash: 'ch01', srcName: 'TestCompanion1' }),
|
||
},
|
||
{
|
||
raw_hex: 'AABBCCDD00112233',
|
||
timestamp: now, observer_id: 'test-obs-2', snr: 8, rssi: -70,
|
||
hash: 'test-hash-003', route_type: 3, payload_type: 4, payload_version: 1,
|
||
path_json: JSON.stringify(['1122', 'aabb']),
|
||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'TestSensor1', pubKey: '1122' + '0'.repeat(60), role: 'sensor', lat: 51.5074, lon: -0.1278, flags: { sensor: true } }),
|
||
},
|
||
{
|
||
raw_hex: 'FF00FF00FF00FF00',
|
||
timestamp: now, observer_id: 'test-obs-1', snr: 15, rssi: -60,
|
||
hash: 'test-hash-001', route_type: 1, payload_type: 4, payload_version: 1,
|
||
path_json: JSON.stringify(['aabb', 'ccdd']),
|
||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'TestRepeater1', pubKey: 'aabb' + '0'.repeat(60) }),
|
||
},
|
||
{
|
||
raw_hex: '5566778899AABB00',
|
||
timestamp: now, observer_id: 'test-obs-2', snr: 3, rssi: -90,
|
||
hash: 'test-hash-004', route_type: 0, payload_type: 5, payload_version: 1,
|
||
path_json: JSON.stringify(['eeff', 'aabb', 'ccdd', '1122']),
|
||
decoded_json: JSON.stringify({ type: 'TXT_MSG', text: 'Another msg', channelHash: 'ch02', srcName: 'TestRoom1' }),
|
||
},
|
||
];
|
||
|
||
for (const pkt of packets) {
|
||
try { pktStore.insert(pkt); } catch {}
|
||
try { db.insertTransmission(pkt); } catch {}
|
||
}
|
||
|
||
// Seed another packet with CHAN type for channel messages
|
||
const chanPkt = {
|
||
raw_hex: 'AA00BB00CC00DD00',
|
||
timestamp: now, observer_id: 'test-obs-1', observer_name: 'TestObs', snr: 5, rssi: -80,
|
||
hash: 'test-hash-005', route_type: 0, payload_type: 5, payload_version: 1,
|
||
path_json: JSON.stringify(['aabb', 'ccdd']),
|
||
decoded_json: JSON.stringify({ type: 'CHAN', channel: 'ch01', text: 'UserA: Hello world', sender: 'UserA', sender_timestamp: now, SNR: 5 }),
|
||
};
|
||
try { pktStore.insert(chanPkt); } catch {}
|
||
try { db.insertTransmission(chanPkt); } catch {}
|
||
|
||
// Another CHAN message for dedup code path coverage
|
||
const chanPkt3 = {
|
||
raw_hex: 'FF00EE00DD00CC00',
|
||
timestamp: now, observer_id: 'test-obs-1', observer_name: 'TestObs', snr: 7, rssi: -75,
|
||
hash: 'test-hash-006', route_type: 1, payload_type: 5, payload_version: 1,
|
||
path_json: JSON.stringify([]),
|
||
decoded_json: JSON.stringify({ type: 'CHAN', channel: 'ch01', text: 'UserB: Test msg', sender: 'UserB' }),
|
||
};
|
||
try { pktStore.insert(chanPkt3); } catch {}
|
||
try { db.insertTransmission(chanPkt3); } catch {}
|
||
|
||
// Duplicate of same message from different observer (for dedup/repeats coverage)
|
||
const chanPkt2 = {
|
||
raw_hex: 'AA00BB00CC00DD00',
|
||
timestamp: now, observer_id: 'test-obs-2', observer_name: 'TestObs2', snr: 3, rssi: -90,
|
||
hash: 'test-hash-005', route_type: 0, payload_type: 5, payload_version: 1,
|
||
path_json: JSON.stringify(['aabb', 'ccdd']),
|
||
decoded_json: JSON.stringify({ type: 'CHAN', channel: 'ch01', text: 'UserA: Hello world', sender: 'UserA' }),
|
||
};
|
||
try { pktStore.insert(chanPkt2); } catch {}
|
||
try { db.insertTransmission(chanPkt2); } catch {}
|
||
|
||
// Seed a CHAN packet with garbage-decrypted name (pre-#197 data) — should be filtered out
|
||
const garbageChanPkt = {
|
||
raw_hex: 'GARBAGE0CHANNEL1',
|
||
timestamp: now, observer_id: 'test-obs-1', observer_name: 'TestObs', snr: 2, rssi: -95,
|
||
hash: 'test-hash-garbage-chan', route_type: 0, payload_type: 5, payload_version: 1,
|
||
path_json: JSON.stringify([]),
|
||
decoded_json: JSON.stringify({ type: 'CHAN', channel: 'garb\x01\x02\x03age', text: 'SomeUser: hello\x04\x05\x06', sender: 'SomeUser' }),
|
||
};
|
||
try { pktStore.insert(garbageChanPkt); } catch {}
|
||
try { db.insertTransmission(garbageChanPkt); } catch {}
|
||
|
||
// Seed a CHAN packet with clean name but garbage text — should also be filtered out
|
||
const garbageTextPkt = {
|
||
raw_hex: 'GARBAGE0TEXT0001',
|
||
timestamp: now, observer_id: 'test-obs-1', observer_name: 'TestObs', snr: 2, rssi: -95,
|
||
hash: 'test-hash-garbage-text', route_type: 0, payload_type: 5, payload_version: 1,
|
||
path_json: JSON.stringify([]),
|
||
decoded_json: JSON.stringify({ type: 'CHAN', channel: 'cleanChan', text: '\x00\x01\x02\x03garbage binary', sender: 'User' }),
|
||
};
|
||
try { pktStore.insert(garbageTextPkt); } catch {}
|
||
try { db.insertTransmission(garbageTextPkt); } catch {}
|
||
|
||
// Packet with sender_key/recipient_key for peer interaction coverage in db.getNodeAnalytics
|
||
const peerPkt = {
|
||
raw_hex: 'DEADBEEF00112233',
|
||
timestamp: yesterday, observer_id: 'test-obs-1', observer_name: 'TestObs', snr: 8, rssi: -82,
|
||
hash: 'test-hash-007', route_type: 0, payload_type: 2, payload_version: 1,
|
||
path_json: JSON.stringify(['aabb']),
|
||
decoded_json: JSON.stringify({ type: 'TXT_MSG', sender_key: 'aabb' + '0'.repeat(60), sender_name: 'TestRepeater1', recipient_key: 'ccdd' + '0'.repeat(60), recipient_name: 'TestRoom1', text: 'hello' }),
|
||
};
|
||
try { pktStore.insert(peerPkt); } catch {}
|
||
try { db.insertTransmission(peerPkt); } catch {}
|
||
|
||
// Clear cache so fresh data is picked up
|
||
cache.clear();
|
||
}
|
||
|
||
seedTestData();
|
||
|
||
(async () => {
|
||
console.log('── Server Route Tests ──');
|
||
|
||
// --- Config routes ---
|
||
await t('GET /api/config/cache', async () => {
|
||
const r = await request(app).get('/api/config/cache').expect(200);
|
||
assert(r.body && typeof r.body === 'object', 'should return object');
|
||
});
|
||
|
||
await t('GET /api/config/client', async () => {
|
||
const r = await request(app).get('/api/config/client').expect(200);
|
||
assert(typeof r.body === 'object', 'should return config');
|
||
});
|
||
|
||
await t('GET /api/config/regions', async () => {
|
||
const r = await request(app).get('/api/config/regions').expect(200);
|
||
assert(typeof r.body === 'object', 'should return regions');
|
||
});
|
||
|
||
await t('GET /api/config/theme', async () => {
|
||
const r = await request(app).get('/api/config/theme').expect(200);
|
||
assert(typeof r.body === 'object', 'should return theme object');
|
||
});
|
||
|
||
await t('GET /api/config/map', async () => {
|
||
const r = await request(app).get('/api/config/map').expect(200);
|
||
assert(typeof r.body === 'object', 'should return map config');
|
||
});
|
||
|
||
// --- Health ---
|
||
await t('GET /api/health', async () => {
|
||
const r = await request(app).get('/api/health').expect(200);
|
||
assert(r.body.status, 'should have status');
|
||
assert(r.body.engine === 'node', 'health should include engine=node');
|
||
assert(typeof r.body.version === 'string', 'health should include version');
|
||
assert(typeof r.body.commit === 'string', 'health should include commit');
|
||
});
|
||
|
||
// --- Stats ---
|
||
await t('GET /api/stats', async () => {
|
||
const r = await request(app).get('/api/stats').expect(200);
|
||
assert(typeof r.body === 'object', 'should return stats');
|
||
assert(r.body.engine === 'node', 'stats should include engine=node');
|
||
assert(typeof r.body.version === 'string', 'stats should include version');
|
||
assert(typeof r.body.commit === 'string', 'stats should include commit');
|
||
});
|
||
|
||
// --- Perf ---
|
||
await t('GET /api/perf', async () => {
|
||
const r = await request(app).get('/api/perf').expect(200);
|
||
assert(typeof r.body === 'object', 'should return perf data');
|
||
});
|
||
|
||
await t('POST /api/perf/reset', async () => {
|
||
const r = await request(app).post('/api/perf/reset');
|
||
assert(r.status === 200 || r.status === 403, 'should return 200 or 403');
|
||
});
|
||
|
||
// --- Nodes ---
|
||
await t('GET /api/nodes default', async () => {
|
||
const r = await request(app).get('/api/nodes').expect(200);
|
||
assert(Array.isArray(r.body) || r.body.nodes, 'should return nodes');
|
||
});
|
||
|
||
await t('GET /api/nodes with limit', async () => {
|
||
await request(app).get('/api/nodes?limit=5').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with offset', async () => {
|
||
await request(app).get('/api/nodes?limit=2&offset=1').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with role=repeater', async () => {
|
||
await request(app).get('/api/nodes?role=repeater').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with role=room', async () => {
|
||
await request(app).get('/api/nodes?role=room').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with region=SFO', async () => {
|
||
await request(app).get('/api/nodes?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with search', async () => {
|
||
await request(app).get('/api/nodes?search=Test').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with lastHeard', async () => {
|
||
await request(app).get('/api/nodes?lastHeard=86400').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with sortBy=name', async () => {
|
||
await request(app).get('/api/nodes?sortBy=name').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with sortBy=role', async () => {
|
||
await request(app).get('/api/nodes?sortBy=role').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with before cursor', async () => {
|
||
await request(app).get('/api/nodes?before=2099-01-01T00:00:00Z').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes with large limit', async () => {
|
||
await request(app).get('/api/nodes?limit=10000&lastHeard=259200').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes/search with q', async () => {
|
||
const r = await request(app).get('/api/nodes/search?q=Test').expect(200);
|
||
assert(Array.isArray(r.body) || typeof r.body === 'object', 'should return results');
|
||
});
|
||
|
||
await t('GET /api/nodes/search without q', async () => {
|
||
await request(app).get('/api/nodes/search').expect(200);
|
||
});
|
||
|
||
await t('GET /api/nodes/bulk-health', async () => {
|
||
const r = await request(app).get('/api/nodes/bulk-health').expect(200);
|
||
assert(typeof r.body === 'object', 'should return bulk health');
|
||
});
|
||
|
||
await t('GET /api/nodes/network-status', async () => {
|
||
const r = await request(app).get('/api/nodes/network-status').expect(200);
|
||
assert(typeof r.body === 'object', 'should return network status');
|
||
});
|
||
|
||
cache.clear(); // Clear to avoid cache hits for regional queries
|
||
await t('GET /api/nodes/network-status with region', async () => {
|
||
await request(app).get('/api/nodes/network-status?region=SFO').expect(200);
|
||
});
|
||
|
||
cache.clear();
|
||
await t('GET /api/nodes/bulk-health with region', async () => {
|
||
await request(app).get('/api/nodes/bulk-health?region=SFO').expect(200);
|
||
});
|
||
|
||
// Test with real node pubkey
|
||
const testPubkey = 'aabb' + '0'.repeat(60);
|
||
|
||
await t('GET /api/nodes/:pubkey — existing', async () => {
|
||
const r = await request(app).get(`/api/nodes/${testPubkey}`);
|
||
assert(r.status === 200 || r.status === 404, 'should find or not find');
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey — nonexistent', async () => {
|
||
await request(app).get('/api/nodes/' + '0'.repeat(64)).expect(404);
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/health — existing', async () => {
|
||
const r = await request(app).get(`/api/nodes/${testPubkey}/health`);
|
||
assert(r.status === 200 || r.status === 404, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/health — nonexistent', async () => {
|
||
const r = await request(app).get('/api/nodes/nonexistent/health');
|
||
assert(r.status === 404 || r.status === 200, 'should handle missing node');
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/paths — existing', async () => {
|
||
const r = await request(app).get(`/api/nodes/${testPubkey}/paths`);
|
||
assert(r.status === 200 || r.status === 404, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/paths — nonexistent', async () => {
|
||
const r = await request(app).get('/api/nodes/nonexistent/paths');
|
||
assert(r.status === 404 || r.status === 200, 'should handle missing');
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/paths with days param', async () => {
|
||
await request(app).get(`/api/nodes/${testPubkey}/paths?days=7`);
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/analytics — existing', async () => {
|
||
const r = await request(app).get(`/api/nodes/${testPubkey}/analytics`);
|
||
assert(r.status === 200 || r.status === 404, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/analytics with days', async () => {
|
||
await request(app).get(`/api/nodes/${testPubkey}/analytics?days=7`);
|
||
});
|
||
|
||
await t('GET /api/nodes/:pubkey/analytics — nonexistent', async () => {
|
||
const r = await request(app).get('/api/nodes/nonexistent/analytics');
|
||
assert(r.status === 404 || r.status === 200, 'should handle missing');
|
||
});
|
||
|
||
// --- Packets ---
|
||
await t('GET /api/packets default', async () => {
|
||
const r = await request(app).get('/api/packets').expect(200);
|
||
assert(typeof r.body === 'object', 'should return packets');
|
||
});
|
||
|
||
await t('GET /api/packets with limit', async () => {
|
||
await request(app).get('/api/packets?limit=5').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with offset', async () => {
|
||
await request(app).get('/api/packets?limit=5&offset=0').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with type', async () => {
|
||
await request(app).get('/api/packets?type=ADVERT').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with route', async () => {
|
||
await request(app).get('/api/packets?route=1').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with observer', async () => {
|
||
await request(app).get('/api/packets?observer=test-obs-1').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with region', async () => {
|
||
await request(app).get('/api/packets?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with hash', async () => {
|
||
await request(app).get('/api/packets?hash=test-hash-001').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with since/until', async () => {
|
||
await request(app).get('/api/packets?since=2020-01-01T00:00:00Z&until=2099-01-01T00:00:00Z').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with groupByHash', async () => {
|
||
await request(app).get('/api/packets?groupByHash=true').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with node filter', async () => {
|
||
await request(app).get(`/api/packets?node=${testPubkey}`).expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with nodes (multi)', async () => {
|
||
await request(app).get(`/api/packets?nodes=${testPubkey},ccdd${'0'.repeat(60)}`).expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets with order asc', async () => {
|
||
await request(app).get('/api/packets?order=asc').expect(200);
|
||
});
|
||
|
||
await t('GET /api/packets/timestamps without since', async () => {
|
||
await request(app).get('/api/packets/timestamps').expect(400);
|
||
});
|
||
|
||
await t('GET /api/packets/timestamps with since', async () => {
|
||
const r = await request(app).get('/api/packets/timestamps?since=2020-01-01T00:00:00Z').expect(200);
|
||
assert(typeof r.body === 'object', 'should return timestamps');
|
||
});
|
||
|
||
await t('GET /api/packets/:id — id 1', async () => {
|
||
const r = await request(app).get('/api/packets/1');
|
||
assert(r.status === 200 || r.status === 404, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/packets/:id — nonexistent', async () => {
|
||
const r = await request(app).get('/api/packets/999999');
|
||
assert(r.status === 404 || r.status === 200, 'should handle missing packet');
|
||
});
|
||
|
||
// --- POST /api/decode ---
|
||
await t('POST /api/decode without hex', async () => {
|
||
await request(app).post('/api/decode').send({}).expect(400);
|
||
});
|
||
|
||
await t('POST /api/decode with invalid hex', async () => {
|
||
await request(app).post('/api/decode').send({ hex: 'zzzz' }).expect(400);
|
||
});
|
||
|
||
await t('POST /api/decode with valid hex', async () => {
|
||
const r = await request(app).post('/api/decode')
|
||
.send({ hex: '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172' });
|
||
assert(r.status === 200 || r.status === 400, 'should not crash');
|
||
});
|
||
|
||
// --- POST /api/packets ---
|
||
await t('POST /api/packets without hex', async () => {
|
||
await request(app).post('/api/packets').send({}).expect(400);
|
||
});
|
||
|
||
await t('POST /api/packets with hex (no api key configured)', async () => {
|
||
const r = await request(app).post('/api/packets')
|
||
.send({ hex: '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172', observer: 'test-obs-1', region: 'SFO' });
|
||
assert(r.status === 200 || r.status === 400 || r.status === 403, 'should handle');
|
||
});
|
||
|
||
await t('POST /api/packets with invalid hex', async () => {
|
||
const r = await request(app).post('/api/packets').send({ hex: 'zzzz' });
|
||
assert(r.status === 400, 'should reject invalid hex');
|
||
});
|
||
|
||
// --- Channels (clear cache first to ensure fresh data) ---
|
||
cache.clear();
|
||
await t('GET /api/channels', async () => {
|
||
const r = await request(app).get('/api/channels').expect(200);
|
||
assert(typeof r.body === 'object', 'should return channels');
|
||
});
|
||
|
||
await t('GET /api/channels filters garbage channel names', async () => {
|
||
cache.clear();
|
||
const r = await request(app).get('/api/channels').expect(200);
|
||
const names = r.body.channels.map(c => c.name);
|
||
assert(!names.some(n => n.includes('\x01') || n.includes('\x02') || n.includes('\x03')),
|
||
'garbage channel names should be filtered out');
|
||
assert(!names.includes('cleanChan'),
|
||
'channels with garbage text should be filtered out');
|
||
assert(names.includes('ch01'), 'valid channel ch01 should still be present');
|
||
});
|
||
|
||
await t('GET /api/channels with region', async () => {
|
||
await request(app).get('/api/channels?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/channels/:hash/messages', async () => {
|
||
const r = await request(app).get('/api/channels/ch01/messages').expect(200);
|
||
assert(typeof r.body === 'object', 'should return messages');
|
||
});
|
||
|
||
await t('GET /api/channels/:hash/messages with params', async () => {
|
||
await request(app).get('/api/channels/ch01/messages?limit=5&offset=0').expect(200);
|
||
});
|
||
|
||
await t('GET /api/channels/:hash/messages with region', async () => {
|
||
await request(app).get('/api/channels/ch01/messages?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/channels/:hash/messages nonexistent', async () => {
|
||
await request(app).get('/api/channels/nonexistent/messages').expect(200);
|
||
});
|
||
|
||
// --- Observers ---
|
||
await t('GET /api/observers', async () => {
|
||
const r = await request(app).get('/api/observers').expect(200);
|
||
assert(typeof r.body === 'object', 'should return observers');
|
||
});
|
||
|
||
await t('GET /api/observers — packetsLastHour regression #182', async () => {
|
||
// Clear cache so the handler re-computes counts
|
||
cache.clear();
|
||
const r = await request(app).get('/api/observers').expect(200);
|
||
const observers = r.body.observers || [];
|
||
const obs1 = observers.find(o => o.id === 'test-obs-1');
|
||
if (obs1) {
|
||
assert(typeof obs1.packetsLastHour === 'number', 'packetsLastHour must be a number');
|
||
// test-obs-1 has recent seeded packets — must not be zero
|
||
assert(obs1.packetsLastHour > 0, `packetsLastHour should be > 0, got ${obs1.packetsLastHour} (regression #182)`);
|
||
}
|
||
});
|
||
|
||
await t('GET /api/observers/:id — existing', async () => {
|
||
const r = await request(app).get('/api/observers/test-obs-1');
|
||
assert(r.status === 200 || r.status === 404, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/observers/:id — nonexistent', async () => {
|
||
const r = await request(app).get('/api/observers/nonexistent');
|
||
assert(r.status === 404 || r.status === 200, 'should handle missing observer');
|
||
});
|
||
|
||
await t('GET /api/observers/:id/analytics — existing', async () => {
|
||
const r = await request(app).get('/api/observers/test-obs-1/analytics');
|
||
assert(r.status === 200 || r.status === 404, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/observers/:id/analytics — nonexistent', async () => {
|
||
const r = await request(app).get('/api/observers/nonexistent/analytics');
|
||
assert(r.status === 404 || r.status === 200, 'should handle');
|
||
});
|
||
|
||
// --- Traces ---
|
||
await t('GET /api/traces/:hash — existing', async () => {
|
||
const r = await request(app).get('/api/traces/test-hash-001');
|
||
assert(r.status === 200 || r.status === 404, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/traces/:hash — nonexistent', async () => {
|
||
const r = await request(app).get('/api/traces/nonexistent');
|
||
assert(r.status === 200 || r.status === 404, 'should handle trace lookup');
|
||
});
|
||
|
||
// --- Analytics (clear cache before regional tests) ---
|
||
cache.clear();
|
||
await t('GET /api/analytics/rf', async () => {
|
||
const r = await request(app).get('/api/analytics/rf').expect(200);
|
||
assert(typeof r.body === 'object', 'should return RF analytics');
|
||
});
|
||
|
||
await t('GET /api/analytics/rf with region', async () => {
|
||
await request(app).get('/api/analytics/rf?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/analytics/rf with region NYC', async () => {
|
||
await request(app).get('/api/analytics/rf?region=NYC').expect(200);
|
||
});
|
||
|
||
await t('GET /api/analytics/topology', async () => {
|
||
const r = await request(app).get('/api/analytics/topology').expect(200);
|
||
assert(typeof r.body === 'object', 'should return topology');
|
||
});
|
||
|
||
await t('GET /api/analytics/topology uniqueNodes matches stats totalNodes (#155)', async () => {
|
||
const topo = await request(app).get('/api/analytics/topology').expect(200);
|
||
const stats = await request(app).get('/api/stats').expect(200);
|
||
assert(topo.body.uniqueNodes === stats.body.totalNodes,
|
||
`uniqueNodes (${topo.body.uniqueNodes}) should match stats totalNodes (${stats.body.totalNodes})`);
|
||
});
|
||
|
||
await t('GET /api/analytics/topology with region', async () => {
|
||
await request(app).get('/api/analytics/topology?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/analytics/channels', async () => {
|
||
const r = await request(app).get('/api/analytics/channels').expect(200);
|
||
assert(typeof r.body === 'object', 'should return channel analytics');
|
||
});
|
||
|
||
await t('GET /api/analytics/channels with region', async () => {
|
||
await request(app).get('/api/analytics/channels?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/analytics/hash-sizes', async () => {
|
||
const r = await request(app).get('/api/analytics/hash-sizes').expect(200);
|
||
assert(typeof r.body === 'object', 'should return hash sizes');
|
||
});
|
||
|
||
await t('GET /api/analytics/hash-sizes with region', async () => {
|
||
await request(app).get('/api/analytics/hash-sizes?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/analytics/subpaths', async () => {
|
||
const r = await request(app).get('/api/analytics/subpaths').expect(200);
|
||
assert(typeof r.body === 'object', 'should return subpaths');
|
||
});
|
||
|
||
await t('GET /api/analytics/subpaths with params', async () => {
|
||
await request(app).get('/api/analytics/subpaths?minLen=2&maxLen=3&limit=10').expect(200);
|
||
});
|
||
|
||
await t('GET /api/analytics/subpaths with region', async () => {
|
||
await request(app).get('/api/analytics/subpaths?region=SFO').expect(200);
|
||
});
|
||
|
||
await t('GET /api/analytics/subpath-detail with hops', async () => {
|
||
const r = await request(app).get('/api/analytics/subpath-detail?hops=aabb,ccdd');
|
||
assert(r.status === 200 || r.status === 400, 'should handle');
|
||
});
|
||
|
||
await t('GET /api/analytics/subpath-detail without hops', async () => {
|
||
const r = await request(app).get('/api/analytics/subpath-detail');
|
||
assert(r.status === 200 || r.status === 400, 'should handle missing hops');
|
||
});
|
||
|
||
await t('GET /api/analytics/distance', async () => {
|
||
const r = await request(app).get('/api/analytics/distance').expect(200);
|
||
assert(typeof r.body === 'object', 'should return distance analytics');
|
||
});
|
||
|
||
await t('GET /api/analytics/distance with region', async () => {
|
||
await request(app).get('/api/analytics/distance?region=SFO').expect(200);
|
||
});
|
||
|
||
// --- Resolve hops ---
|
||
await t('GET /api/resolve-hops with hops', async () => {
|
||
const r = await request(app).get('/api/resolve-hops?hops=aabb,ccdd').expect(200);
|
||
assert(typeof r.body === 'object', 'should return resolved hops');
|
||
});
|
||
|
||
await t('GET /api/resolve-hops without hops', async () => {
|
||
await request(app).get('/api/resolve-hops').expect(200);
|
||
});
|
||
|
||
await t('GET /api/resolve-hops with region and observer', async () => {
|
||
await request(app).get('/api/resolve-hops?hops=aabb,ccdd®ion=SFO&observer=test-obs-1').expect(200);
|
||
});
|
||
|
||
await t('GET /api/resolve-hops with prefixes (legacy)', async () => {
|
||
await request(app).get('/api/resolve-hops?prefixes=aabb,ccdd').expect(200);
|
||
});
|
||
|
||
await t('GET /api/resolve-hops ambiguous prefix', async () => {
|
||
// 'aabb' matches both TestRepeater1 and TestRepeater2
|
||
const r = await request(app).get('/api/resolve-hops?hops=aabb,ccdd,1122®ion=SFO&observer=test-obs-1').expect(200);
|
||
assert(typeof r.body === 'object', 'should resolve hops');
|
||
});
|
||
|
||
await t('GET /api/resolve-hops with packet context', async () => {
|
||
await request(app).get('/api/resolve-hops?hops=aabb,eeff,ccdd®ion=SFO&observer=test-obs-1&packetHash=test-hash-001').expect(200);
|
||
});
|
||
|
||
// --- IATA coords ---
|
||
await t('GET /api/iata-coords', async () => {
|
||
const r = await request(app).get('/api/iata-coords').expect(200);
|
||
assert(r.body && 'coords' in r.body, 'should have coords key');
|
||
});
|
||
|
||
// --- Audio lab ---
|
||
await t('GET /api/audio-lab/buckets', async () => {
|
||
const r = await request(app).get('/api/audio-lab/buckets').expect(200);
|
||
assert(r.body && 'buckets' in r.body, 'should have buckets key');
|
||
});
|
||
|
||
// --- SPA fallback ---
|
||
await t('GET /nodes SPA fallback', async () => {
|
||
const r = await request(app).get('/nodes');
|
||
assert([200, 304, 404].includes(r.status), 'should not crash');
|
||
});
|
||
|
||
// --- Cache behavior: hit same endpoint twice ---
|
||
await t('Cache hit: /api/nodes/bulk-health twice', async () => {
|
||
await request(app).get('/api/nodes/bulk-health').expect(200);
|
||
await request(app).get('/api/nodes/bulk-health').expect(200);
|
||
});
|
||
|
||
await t('Cache hit: /api/analytics/rf twice', async () => {
|
||
await request(app).get('/api/analytics/rf').expect(200);
|
||
await request(app).get('/api/analytics/rf').expect(200);
|
||
});
|
||
|
||
await t('Cache hit: /api/analytics/topology twice', async () => {
|
||
await request(app).get('/api/analytics/topology').expect(200);
|
||
await request(app).get('/api/analytics/topology').expect(200);
|
||
});
|
||
|
||
// ── WebSocket tests ──
|
||
await t('WebSocket connection', async () => {
|
||
const WebSocket = require('ws');
|
||
await new Promise((resolve) => {
|
||
if (server.address()) return resolve();
|
||
server.listen(0, resolve);
|
||
});
|
||
const port = server.address().port;
|
||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||
await new Promise((resolve, reject) => {
|
||
ws.on('open', resolve);
|
||
ws.on('error', reject);
|
||
setTimeout(() => reject(new Error('WS timeout')), 3000);
|
||
});
|
||
assert(ws.readyState === WebSocket.OPEN, 'WS should be open');
|
||
ws.close();
|
||
await new Promise(r => setTimeout(r, 100));
|
||
});
|
||
|
||
// ── Additional query parameter branches ──
|
||
await t('GET /api/nodes?sortBy=lastSeen', async () => {
|
||
await request(app).get('/api/nodes?sortBy=lastSeen').expect(200);
|
||
});
|
||
await t('GET /api/nodes multi-filter', async () => {
|
||
await request(app).get('/api/nodes?role=repeater®ion=SFO&lastHeard=86400&search=Test').expect(200);
|
||
});
|
||
await t('GET /api/packets?type=4', async () => {
|
||
await request(app).get('/api/packets?type=4').expect(200);
|
||
});
|
||
await t('GET /api/packets multi-filter', async () => {
|
||
await request(app).get('/api/packets?type=5&route=1&observer=test-obs-1').expect(200);
|
||
});
|
||
await t('GET /api/packets?node=TestRepeater1', async () => {
|
||
await request(app).get('/api/packets?node=TestRepeater1').expect(200);
|
||
});
|
||
await t('GET /api/packets?groupByHash=true&type=4&observer=test-obs-1', async () => {
|
||
await request(app).get('/api/packets?groupByHash=true&type=4&observer=test-obs-1').expect(200);
|
||
});
|
||
await t('GET /api/packets?groupByHash=true®ion=SFO', async () => {
|
||
await request(app).get('/api/packets?groupByHash=true®ion=SFO').expect(200);
|
||
});
|
||
await t('GET /api/nodes?sortBy=name&search=nonexistent', async () => {
|
||
const r = await request(app).get('/api/nodes?sortBy=name&search=nonexistent').expect(200);
|
||
assert(r.body.nodes.length === 0, 'no nodes');
|
||
});
|
||
await t('GET /api/nodes?before=2000-01-01', async () => {
|
||
const r = await request(app).get('/api/nodes?before=2000-01-01T00:00:00Z').expect(200);
|
||
assert(r.body.nodes.length === 0, 'no nodes');
|
||
});
|
||
|
||
// ── Node health/analytics/paths for nodes WITH packets ──
|
||
const testRepeaterKey = 'aabb' + '0'.repeat(60);
|
||
|
||
await t('GET /api/nodes/:pubkey/health with packets', async () => {
|
||
cache.clear();
|
||
const r = await request(app).get(`/api/nodes/${testRepeaterKey}/health`).expect(200);
|
||
assert(r.body.node && r.body.stats, 'should have node+stats');
|
||
});
|
||
await t('GET /api/nodes/:pubkey/analytics?days=30', async () => {
|
||
cache.clear();
|
||
const r = await request(app).get(`/api/nodes/${testRepeaterKey}/analytics?days=30`).expect(200);
|
||
assert(r.body.computedStats, 'should have computedStats');
|
||
});
|
||
await t('GET /api/nodes/:pubkey/analytics?days=1', async () => {
|
||
cache.clear();
|
||
await request(app).get(`/api/nodes/${testRepeaterKey}/analytics?days=1`).expect(200);
|
||
});
|
||
await t('GET /api/nodes/:pubkey/paths with packets', async () => {
|
||
cache.clear();
|
||
const r = await request(app).get(`/api/nodes/${testRepeaterKey}/paths`).expect(200);
|
||
assert(r.body.paths !== undefined, 'should have paths');
|
||
});
|
||
await t('GET /api/nodes/:pubkey existing', async () => {
|
||
await request(app).get(`/api/nodes/${testRepeaterKey}`).expect(200);
|
||
});
|
||
await t('GET /api/nodes/:pubkey 404', async () => {
|
||
await request(app).get('/api/nodes/' + '0'.repeat(63) + '1').expect(404);
|
||
});
|
||
|
||
// ── Observer analytics ──
|
||
await t('GET /api/observers/test-obs-1/analytics', async () => {
|
||
cache.clear();
|
||
const r = await request(app).get('/api/observers/test-obs-1/analytics').expect(200);
|
||
assert(r.body.timeline !== undefined, 'should have timeline');
|
||
});
|
||
await t('GET /api/observers/test-obs-1/analytics?days=1', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/observers/test-obs-1/analytics?days=1').expect(200);
|
||
});
|
||
|
||
// ── Traces ──
|
||
await t('GET /api/traces/:hash existing', async () => {
|
||
const r = await request(app).get('/api/traces/test-hash-001').expect(200);
|
||
assert(r.body.traces, 'should have traces');
|
||
});
|
||
|
||
// ── Resolve hops ──
|
||
await t('GET /api/resolve-hops?hops=aabb', async () => {
|
||
await request(app).get('/api/resolve-hops?hops=aabb').expect(200);
|
||
});
|
||
await t('GET /api/resolve-hops?hops=ffff', async () => {
|
||
await request(app).get('/api/resolve-hops?hops=ffff').expect(200);
|
||
});
|
||
|
||
// ── Analytics with nonexistent region ──
|
||
for (const ep of ['rf', 'topology', 'channels', 'distance', 'hash-sizes']) {
|
||
await t(`GET /api/analytics/${ep}?region=ZZZZ`, async () => {
|
||
cache.clear();
|
||
await request(app).get(`/api/analytics/${ep}?region=ZZZZ`).expect(200);
|
||
});
|
||
}
|
||
|
||
// ── Subpath endpoints ──
|
||
await t('GET /api/analytics/subpaths?minLen=2&maxLen=2', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/analytics/subpaths?minLen=2&maxLen=2&limit=5').expect(200);
|
||
});
|
||
await t('GET /api/analytics/subpath-detail?hops=aabb,ccdd,eeff', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/analytics/subpath-detail?hops=aabb,ccdd,eeff').expect(200);
|
||
});
|
||
|
||
// ── POST /api/packets with observer+region ──
|
||
await t('POST /api/packets with observer+region', async () => {
|
||
const r = await request(app).post('/api/packets')
|
||
.send({ hex: '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172', observer: 'test-api-obs', region: 'LAX', snr: 12, rssi: -75 });
|
||
assert([200, 400, 403].includes(r.status), 'should handle');
|
||
});
|
||
|
||
// ── POST /api/decode with whitespace ──
|
||
await t('POST /api/decode whitespace hex', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: ' 1145 1000 D818 206D ' });
|
||
assert([200, 400].includes(r.status), 'should handle');
|
||
});
|
||
|
||
// ── Direct db function calls ──
|
||
await t('db.searchNodes', () => {
|
||
assert(Array.isArray(db.searchNodes('Test', 10)), 'should return array');
|
||
assert(db.searchNodes('zzzznonexistent', 5).length === 0, 'empty for no match');
|
||
});
|
||
await t('db.getNodeHealth existing', () => {
|
||
const h = db.getNodeHealth(testRepeaterKey);
|
||
assert(h && h.node && h.stats, 'should have node+stats');
|
||
});
|
||
await t('db.getNodeHealth nonexistent', () => {
|
||
assert(db.getNodeHealth('nonexistent') === null, 'null for missing');
|
||
});
|
||
await t('db.getNodeAnalytics existing', () => {
|
||
const a = db.getNodeAnalytics(testRepeaterKey, 7);
|
||
assert(a && a.computedStats, 'should have computedStats');
|
||
});
|
||
await t('db.getNodeAnalytics nonexistent', () => {
|
||
assert(db.getNodeAnalytics('nonexistent', 7) === null, 'null for missing');
|
||
});
|
||
await t('db.getNodeAnalytics for named node', () => {
|
||
const a = db.getNodeAnalytics('ccdd' + '0'.repeat(60), 30);
|
||
assert(a !== null, 'should return analytics');
|
||
});
|
||
await t('db.getNodeHealth for named node', () => {
|
||
assert(db.getNodeHealth('ccdd' + '0'.repeat(60)) !== null, 'should have health');
|
||
});
|
||
await t('db.updateObserverStatus', () => {
|
||
db.updateObserverStatus({ id: 'test-status-obs', name: 'StatusObs', iata: 'LAX', model: 'test', firmware: '1.0', client_version: '2.0', radio: '915,125,7,5', battery_mv: 3700, uptime_secs: 86400, noise_floor: -120 });
|
||
});
|
||
|
||
// ── Packet store direct methods ──
|
||
await t('pktStore.getById missing', () => { assert(pktStore.getById(999999) === null); });
|
||
await t('pktStore.getSiblings missing', () => { assert(pktStore.getSiblings('nonexistent').length === 0); });
|
||
await t('pktStore.getTimestamps', () => { assert(Array.isArray(pktStore.getTimestamps('2000-01-01T00:00:00Z'))); });
|
||
await t('pktStore.all', () => { assert(Array.isArray(pktStore.all())); });
|
||
await t('pktStore.filter', () => { assert(Array.isArray(pktStore.filter(p => p.payload_type === 4))); });
|
||
await t('pktStore.getStats', () => { const s = pktStore.getStats(); assert(s.inMemory !== undefined && s.indexes); });
|
||
await t('pktStore.queryGrouped', () => { assert(pktStore.queryGrouped({ limit: 5, type: 4 }).packets !== undefined); });
|
||
await t('pktStore.queryGrouped region+since', () => { assert(pktStore.queryGrouped({ limit: 5, region: 'SFO', since: '2000-01-01' }).packets !== undefined); });
|
||
await t('pktStore.countForNode existing', () => { assert(pktStore.countForNode(testRepeaterKey).transmissions !== undefined); });
|
||
await t('pktStore.countForNode missing', () => { assert(pktStore.countForNode('nonexistent').transmissions === 0); });
|
||
await t('pktStore.findPacketsForNode', () => { assert(pktStore.findPacketsForNode(testRepeaterKey).packets !== undefined); });
|
||
await t('pktStore._transmissionsForObserver with fromTx', () => {
|
||
assert(Array.isArray(pktStore._transmissionsForObserver('test-obs-1', pktStore.all())));
|
||
});
|
||
|
||
// ── Cache SWR (stale-while-revalidate) ──
|
||
await t('Cache stale-while-revalidate', async () => {
|
||
// 50ms TTL, grace period = 100ms
|
||
cache.set('swr-test', { data: 42 }, 50);
|
||
await new Promise(r => setTimeout(r, 60)); // Wait past expiry but within grace
|
||
const stale = cache.get('swr-test');
|
||
assert(stale && stale.data === 42, 'should return stale value within grace');
|
||
});
|
||
await t('Cache fully expired (past grace)', async () => {
|
||
cache.set('swr-expired', { data: 99 }, 10);
|
||
await new Promise(r => setTimeout(r, 30)); // Past 2× TTL
|
||
const expired = cache.get('swr-expired');
|
||
assert(expired === undefined, 'should return undefined past grace');
|
||
});
|
||
await t('Cache isStale', async () => {
|
||
cache.set('stale-test', { data: 1 }, 50);
|
||
await new Promise(r => setTimeout(r, 60));
|
||
assert(cache.isStale('stale-test') === true, 'should be stale');
|
||
});
|
||
await t('Cache recompute', () => {
|
||
let ran = false;
|
||
cache.recompute('recompute-test', () => { ran = true; });
|
||
assert(ran === true, 'recompute fn should run');
|
||
});
|
||
await t('Cache debouncedInvalidateAll', async () => {
|
||
cache.debouncedInvalidateAll();
|
||
await new Promise(r => setTimeout(r, 200));
|
||
});
|
||
|
||
// ── Cache operations ──
|
||
await t('Cache set/get/invalidate', () => {
|
||
cache.set('test-key', { data: 1 }, 60000);
|
||
assert(cache.get('test-key').data === 1);
|
||
cache.invalidate('test-key');
|
||
});
|
||
await t('Cache invalidate+refetch', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/stats').expect(200);
|
||
await request(app).get('/api/stats').expect(200);
|
||
});
|
||
|
||
// ── Channel messages fresh (dedup coverage) ──
|
||
await t('GET /api/channels/ch01/messages fresh', async () => {
|
||
cache.clear();
|
||
const r = await request(app).get('/api/channels/ch01/messages').expect(200);
|
||
assert(r.body.messages !== undefined, 'should have messages');
|
||
});
|
||
await t('GET /api/channels/ch01/messages?limit=1&offset=0', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/channels/ch01/messages?limit=1&offset=0').expect(200);
|
||
});
|
||
|
||
// ── Multi-filter packet queries ──
|
||
await t('GET /api/packets?type=4&node=TestRepeater1', async () => {
|
||
await request(app).get('/api/packets?type=4&node=TestRepeater1').expect(200);
|
||
});
|
||
await t('GET /api/packets all filters', async () => {
|
||
await request(app).get('/api/packets?type=4&route=1&since=2000-01-01T00:00:00Z&until=2099-01-01T00:00:00Z&observer=test-obs-1&hash=test-hash-001').expect(200);
|
||
});
|
||
await t('GET /api/packets?type=5®ion=SFO&node=TestRepeater1', async () => {
|
||
await request(app).get('/api/packets?type=5®ion=SFO&node=TestRepeater1&since=2000-01-01T00:00:00Z').expect(200);
|
||
});
|
||
|
||
// ── Perf/nocache bypass ──
|
||
await t('GET /api/stats?nocache=1', async () => {
|
||
await request(app).get('/api/stats?nocache=1').expect(200);
|
||
});
|
||
await t('GET /api/nodes?nocache=1', async () => {
|
||
await request(app).get('/api/nodes?nocache=1').expect(200);
|
||
});
|
||
|
||
// ── More route branch coverage ──
|
||
await t('GET /api/packets/:id by hash', async () => {
|
||
await request(app).get('/api/packets/testhash00000001').expect(404); // 16 hex chars
|
||
});
|
||
await t('GET /api/packets/:id by string non-hash', async () => {
|
||
await request(app).get('/api/packets/not-a-hash-or-number').expect(404);
|
||
});
|
||
|
||
// ── SPA fallback paths ──
|
||
for (const path of ['/map', '/packets', '/analytics', '/live']) {
|
||
await t(`GET ${path} SPA fallback`, async () => {
|
||
const r = await request(app).get(path);
|
||
assert([200, 304, 404].includes(r.status));
|
||
});
|
||
}
|
||
|
||
// ── Network status ──
|
||
await t('GET /api/nodes/network-status?region=ZZZZ', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/nodes/network-status?region=ZZZZ').expect(200);
|
||
});
|
||
|
||
// ── Bulk health variants ──
|
||
await t('GET /api/nodes/bulk-health?limit=2', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/nodes/bulk-health?limit=2').expect(200);
|
||
});
|
||
await t('GET /api/nodes/bulk-health?region=NYC', async () => {
|
||
cache.clear();
|
||
await request(app).get('/api/nodes/bulk-health?region=NYC').expect(200);
|
||
});
|
||
|
||
// ── Decoder: various payload types ──
|
||
await t('POST /api/decode REQ', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '0000' + 'AA'.repeat(20) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'REQ');
|
||
});
|
||
await t('POST /api/decode RESPONSE', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '0400' + 'AA'.repeat(20) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'RESPONSE');
|
||
});
|
||
await t('POST /api/decode TXT_MSG', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '0800' + 'AA'.repeat(20) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'TXT_MSG');
|
||
});
|
||
await t('POST /api/decode ACK', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '0C00' + 'BB'.repeat(6) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'ACK');
|
||
});
|
||
await t('POST /api/decode GRP_TXT', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '1500' + 'CC'.repeat(10) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'GRP_TXT');
|
||
});
|
||
await t('POST /api/decode ANON_REQ', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '1D00' + 'DD'.repeat(40) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'ANON_REQ');
|
||
});
|
||
await t('POST /api/decode PATH', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '2100' + 'EE'.repeat(10) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'PATH');
|
||
});
|
||
await t('POST /api/decode TRACE', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '2500' + 'FF'.repeat(12) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'TRACE');
|
||
});
|
||
await t('POST /api/decode UNKNOWN type', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '3C00' + '00'.repeat(10) });
|
||
assert(r.status === 200 && r.body.decoded.payload.type === 'UNKNOWN');
|
||
});
|
||
await t('POST /api/decode TRANSPORT_FLOOD', async () => {
|
||
const r = await request(app).post('/api/decode').send({ hex: '1200AABBCCDD' + '00'.repeat(101) });
|
||
assert([200, 400].includes(r.status));
|
||
});
|
||
await t('POST /api/decode minimal ADVERT', async () => {
|
||
await request(app).post('/api/decode').send({ hex: '1100' + '00'.repeat(100) }).expect(200);
|
||
});
|
||
await t('POST /api/decode too-short', async () => {
|
||
await request(app).post('/api/decode').send({ hex: 'AA' }).expect(400);
|
||
});
|
||
// Short payloads triggering error branches
|
||
await t('POST /api/decode GRP_TXT too short', async () => {
|
||
await request(app).post('/api/decode').send({ hex: '1500AABB' }).expect(200);
|
||
});
|
||
await t('POST /api/decode ADVERT too short', async () => {
|
||
await request(app).post('/api/decode').send({ hex: '1100' + 'AA'.repeat(10) }).expect(200);
|
||
});
|
||
await t('POST /api/decode TRACE too short', async () => {
|
||
await request(app).post('/api/decode').send({ hex: '2500' + 'FF'.repeat(5) }).expect(200);
|
||
});
|
||
await t('POST /api/decode PATH too short', async () => {
|
||
await request(app).post('/api/decode').send({ hex: '2100' + 'EE'.repeat(2) }).expect(200);
|
||
});
|
||
await t('POST /api/decode ANON_REQ too short', async () => {
|
||
await request(app).post('/api/decode').send({ hex: '1D00' + 'DD'.repeat(10) }).expect(200);
|
||
});
|
||
|
||
// ── server-helpers: disambiguateHops direct tests ──
|
||
await t('disambiguateHops ambiguous multi-candidate', () => {
|
||
const helpers = require('./server-helpers');
|
||
const allNodes = [
|
||
{ public_key: 'aabb' + '0'.repeat(60), name: 'Node1', lat: 37.7, lon: -122.4 },
|
||
{ public_key: 'aabb' + '1'.repeat(60), name: 'Node2', lat: 34.0, lon: -118.2 },
|
||
{ public_key: 'ccdd' + '0'.repeat(60), name: 'Node3', lat: 40.7, lon: -74.0 },
|
||
];
|
||
const resolved = helpers.disambiguateHops(['aabb', 'ccdd'], allNodes);
|
||
assert(resolved.length === 2 && resolved[0].name);
|
||
});
|
||
await t('disambiguateHops backward pass', () => {
|
||
const helpers = require('./server-helpers');
|
||
// Ambiguous first hop, known second → backward pass resolves first
|
||
const allNodes = [
|
||
{ public_key: 'aa' + '3'.repeat(62), name: 'ANode1', lat: 37.7, lon: -122.4 },
|
||
{ public_key: 'aa' + '4'.repeat(62), name: 'ANode2', lat: 34.0, lon: -118.2 },
|
||
{ public_key: 'bb' + '3'.repeat(62), name: 'BNode', lat: 40.7, lon: -74.0 },
|
||
];
|
||
const resolved = helpers.disambiguateHops(['aa', 'bb'], allNodes);
|
||
assert(resolved.length === 2);
|
||
});
|
||
await t('disambiguateHops distance unreliable', () => {
|
||
const helpers = require('./server-helpers');
|
||
const allNodes = [
|
||
{ public_key: 'aa' + '5'.repeat(62), name: 'Near1', lat: 37.7, lon: -122.4 },
|
||
{ public_key: 'bb' + '5'.repeat(62), name: 'FarAway', lat: -33.8, lon: 151.2 },
|
||
{ public_key: 'cc' + '5'.repeat(62), name: 'Near2', lat: 37.8, lon: -122.3 },
|
||
];
|
||
const resolved = helpers.disambiguateHops(['aa', 'bb', 'cc'], allNodes, 0.5);
|
||
assert(resolved[1].unreliable === true, 'middle node should be unreliable');
|
||
});
|
||
await t('disambiguateHops unknown prefix', () => {
|
||
const helpers = require('./server-helpers');
|
||
const allNodes = [{ public_key: 'aabb' + '0'.repeat(60), name: 'Node1', lat: 37.7, lon: -122.4 }];
|
||
const resolved = helpers.disambiguateHops(['ffff', 'aabb'], allNodes);
|
||
assert(resolved[0].known === false);
|
||
});
|
||
await t('disambiguateHops single known match', () => {
|
||
const helpers = require('./server-helpers');
|
||
const allNodes = [{ public_key: 'ccdd' + '6'.repeat(60), name: 'UniqueNode', lat: 40.7, lon: -74.0 }];
|
||
const resolved = helpers.disambiguateHops(['ccdd'], allNodes);
|
||
assert(resolved[0].known === true && resolved[0].name === 'UniqueNode');
|
||
});
|
||
await t('disambiguateHops no-coord node', () => {
|
||
const helpers = require('./server-helpers');
|
||
const allNodes = [{ public_key: 'aabb' + '7'.repeat(60), name: 'NoCoord', lat: 0, lon: 0 }];
|
||
const resolved = helpers.disambiguateHops(['aabb'], allNodes);
|
||
assert(resolved.length === 1);
|
||
});
|
||
|
||
// ── isHashSizeFlipFlop ──
|
||
await t('isHashSizeFlipFlop true', () => {
|
||
const h = require('./server-helpers');
|
||
assert(h.isHashSizeFlipFlop([1, 2, 1, 2], new Set([1, 2])) === true);
|
||
});
|
||
await t('isHashSizeFlipFlop false stable', () => {
|
||
const h = require('./server-helpers');
|
||
assert(h.isHashSizeFlipFlop([1, 1, 1], new Set([1])) === false);
|
||
});
|
||
await t('isHashSizeFlipFlop false short/null', () => {
|
||
const h = require('./server-helpers');
|
||
assert(h.isHashSizeFlipFlop([1, 2], new Set([1, 2])) === false);
|
||
assert(h.isHashSizeFlipFlop(null, null) === false);
|
||
});
|
||
|
||
// ── lastPathSeenMap: repeater hop tracking ──
|
||
await t('node appearing only as path hop gets last_heard', async () => {
|
||
// Create a node that has NO packets in pktStore (only exists in DB)
|
||
const hopPubkey = 'ffaa' + '0'.repeat(60);
|
||
db.upsertNode({ public_key: hopPubkey, name: 'HopOnlyRepeater', role: 'repeater', lat: 0, lon: 0, last_seen: '2020-01-01T00:00:00.000Z' });
|
||
|
||
// Simulate it being seen as a path hop
|
||
const recentTime = new Date().toISOString();
|
||
lastPathSeenMap.set(hopPubkey, recentTime);
|
||
|
||
const res = await request(app).get('/api/nodes?search=HopOnlyRepeater');
|
||
assert(res.status === 200);
|
||
assert(res.body.nodes.length >= 1, 'should find the hop-only node');
|
||
const node = res.body.nodes.find(n => n.public_key === hopPubkey);
|
||
assert(node, 'node should exist in results');
|
||
assert(node.last_heard === recentTime, 'last_heard should come from lastPathSeenMap');
|
||
|
||
// Cleanup
|
||
lastPathSeenMap.delete(hopPubkey);
|
||
});
|
||
|
||
await t('last_heard from path hop preferred over stale last_seen', async () => {
|
||
const hopPubkey = 'ffbb' + '0'.repeat(60);
|
||
const staleTime = '2020-01-01T00:00:00.000Z';
|
||
const freshTime = new Date().toISOString();
|
||
db.upsertNode({ public_key: hopPubkey, name: 'StaleRepeater', role: 'repeater', lat: 0, lon: 0, last_seen: staleTime });
|
||
|
||
// Path hop seen recently
|
||
lastPathSeenMap.set(hopPubkey, freshTime);
|
||
|
||
const res = await request(app).get('/api/nodes?search=StaleRepeater');
|
||
assert(res.status === 200);
|
||
const node = res.body.nodes.find(n => n.public_key === hopPubkey);
|
||
assert(node, 'node should exist');
|
||
assert(node.last_heard === freshTime, 'last_heard should be fresh path time, not stale DB time');
|
||
assert(node.last_seen === staleTime, 'last_seen (DB) should still be stale');
|
||
|
||
lastPathSeenMap.delete(hopPubkey);
|
||
});
|
||
|
||
await t('last_heard from pktStore preferred over older path hop', async () => {
|
||
const hopPubkey = 'aabb' + '0'.repeat(60); // TestRepeater1 — has packets in pktStore
|
||
const oldPathTime = '2019-01-01T00:00:00.000Z';
|
||
lastPathSeenMap.set(hopPubkey, oldPathTime);
|
||
|
||
const res = await request(app).get('/api/nodes?search=TestRepeater1');
|
||
assert(res.status === 200);
|
||
const node = res.body.nodes.find(n => n.public_key === hopPubkey);
|
||
assert(node, 'node should exist');
|
||
// pktStore should have a more recent timestamp than our old path time
|
||
assert(node.last_heard > oldPathTime, 'pktStore timestamp should win over older path hop time');
|
||
|
||
lastPathSeenMap.delete(hopPubkey);
|
||
});
|
||
|
||
await t('bulk-health cache invalidated after advert', () => {
|
||
// Set a fake bulk-health cache entry
|
||
cache.set('bulk-health:50:r=', { fake: true }, 60000);
|
||
assert(cache.get('bulk-health:50:r='), 'cache should have bulk-health entry');
|
||
|
||
// Simulate what happens on advert: cache.invalidate('bulk-health')
|
||
cache.invalidate('bulk-health');
|
||
assert(!cache.get('bulk-health:50:r='), 'bulk-health cache should be invalidated after advert');
|
||
});
|
||
|
||
// ── Issue #126: hash prefix collision — ambiguous hop attribution ──
|
||
|
||
await t('ambiguous hop prefix does NOT update lastPathSeenMap for either node (fixes #126)', async () => {
|
||
// Two nodes sharing prefix 'ab12': collision scenario like 1CC4 vs 1C82
|
||
const nodeA = 'ab12' + 'a'.repeat(60);
|
||
const nodeB = 'ab12' + 'b'.repeat(60);
|
||
db.upsertNode({ public_key: nodeA, name: 'CollidingNodeA', role: 'repeater', lat: 37.0, lon: -122.0, last_seen: '2020-01-01T00:00:00.000Z' });
|
||
db.upsertNode({ public_key: nodeB, name: 'CollidingNodeB', role: 'repeater', lat: 38.0, lon: -121.0, last_seen: '2020-01-01T00:00:00.000Z' });
|
||
|
||
// Clear caches to start fresh
|
||
hopPrefixToKey.delete('ab12');
|
||
ambiguousHopPrefixes.delete('ab12');
|
||
lastPathSeenMap.delete(nodeA);
|
||
lastPathSeenMap.delete(nodeB);
|
||
|
||
// Attempt to resolve the ambiguous prefix — should return null
|
||
const result = resolveUniquePrefixMatch('ab12');
|
||
assert(result === null, 'ambiguous prefix should resolve to null');
|
||
assert(ambiguousHopPrefixes.has('ab12'), 'ab12 should be in ambiguous prefix cache');
|
||
assert(!hopPrefixToKey.has('ab12'), 'ab12 should NOT be in hopPrefixToKey cache');
|
||
|
||
// Verify neither node gets last_heard updated
|
||
assert(!lastPathSeenMap.has(nodeA), 'lastPathSeenMap should NOT have nodeA (ambiguous prefix)');
|
||
assert(!lastPathSeenMap.has(nodeB), 'lastPathSeenMap should NOT have nodeB (ambiguous prefix)');
|
||
|
||
// Subsequent resolution calls should also return null (cached negative result)
|
||
const result2 = resolveUniquePrefixMatch('ab12');
|
||
assert(result2 === null, 'cached ambiguous prefix should still resolve to null');
|
||
|
||
// Cleanup
|
||
hopPrefixToKey.delete('ab12');
|
||
ambiguousHopPrefixes.delete('ab12');
|
||
});
|
||
|
||
await t('unique hop prefix still resolves and updates lastPathSeenMap', async () => {
|
||
// A node with a unique prefix that no other node shares
|
||
const uniqueNode = 'eeee' + 'f'.repeat(60);
|
||
db.upsertNode({ public_key: uniqueNode, name: 'UniqueHopNode', role: 'repeater', lat: 40.0, lon: -74.0, last_seen: '2020-01-01T00:00:00.000Z' });
|
||
|
||
// Clear caches
|
||
hopPrefixToKey.delete('eeee');
|
||
ambiguousHopPrefixes.delete('eeee');
|
||
lastPathSeenMap.delete(uniqueNode);
|
||
|
||
// Resolve the unique prefix — should return the full key
|
||
const result = resolveUniquePrefixMatch('eeee');
|
||
assert(result === uniqueNode, 'unique prefix should resolve to the full public_key');
|
||
assert(hopPrefixToKey.get('eeee') === uniqueNode, 'unique prefix should be cached in hopPrefixToKey');
|
||
assert(!ambiguousHopPrefixes.has('eeee'), 'unique prefix should NOT be in ambiguous cache');
|
||
|
||
// Cleanup
|
||
hopPrefixToKey.delete('eeee');
|
||
lastPathSeenMap.delete(uniqueNode);
|
||
});
|
||
|
||
await t('1-byte ambiguous prefix does NOT update dead node (issue #126 regression)', async () => {
|
||
// Simulate the exact scenario: two nodes with shared 1-byte prefix '1c'
|
||
const deadNode = '1c' + 'c4' + 'dd'.repeat(30);
|
||
const liveNode = '1c' + '82' + 'ee'.repeat(30);
|
||
const staleTime = new Date(Date.now() - 8 * 86400000).toISOString(); // 8 days ago
|
||
const recentTime = new Date().toISOString();
|
||
|
||
db.upsertNode({ public_key: deadNode, name: 'DeadNodeR5D4', role: 'repeater', lat: 35.0, lon: -120.0, last_seen: staleTime });
|
||
db.upsertNode({ public_key: liveNode, name: 'LiveNodeK2S0', role: 'repeater', lat: 35.1, lon: -120.1, last_seen: recentTime });
|
||
|
||
// Clear all caches
|
||
hopPrefixToKey.delete('1c');
|
||
ambiguousHopPrefixes.delete('1c');
|
||
lastPathSeenMap.delete(deadNode);
|
||
lastPathSeenMap.delete(liveNode);
|
||
|
||
// The 1-byte prefix '1c' matches both nodes — should be ambiguous
|
||
const result = resolveUniquePrefixMatch('1c');
|
||
assert(result === null, '1-byte prefix 1c should be ambiguous (matches 2 nodes)');
|
||
|
||
// Fetch DeadNodeR5D4 — should NOT have last_heard from pathSeenMap
|
||
const res = await request(app).get('/api/nodes?search=DeadNodeR5D4');
|
||
assert(res.status === 200);
|
||
const node = res.body.nodes.find(n => n.public_key === deadNode);
|
||
assert(node, 'dead node should exist in DB');
|
||
// last_heard should be null/undefined or equal to the stale last_seen — NOT recent
|
||
const lastHeard = node.last_heard ? new Date(node.last_heard).getTime() : 0;
|
||
const sevenDaysAgo = Date.now() - 7 * 86400000;
|
||
assert(lastHeard < sevenDaysAgo, 'dead node last_heard should NOT be recent (hash collision guard)');
|
||
|
||
// Cleanup
|
||
hopPrefixToKey.delete('1c');
|
||
ambiguousHopPrefixes.delete('1c');
|
||
lastPathSeenMap.delete(deadNode);
|
||
lastPathSeenMap.delete(liveNode);
|
||
});
|
||
|
||
// ── Summary ──
|
||
console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`);
|
||
if (failed > 0) process.exit(1);
|
||
process.exit(0);
|
||
})();
|