mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-25 20:52:14 +00:00
Compare commits
1 Commits
revert/870
...
fix/871
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a714731f0 |
194
cmd/server/issue871_test.go
Normal file
194
cmd/server/issue871_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// setupTestDB871 creates a test DB with schema and returns a read-only *DB handle.
|
||||
func setupTestDB871(t *testing.T) (*DB, *sql.DB) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test871.db")
|
||||
|
||||
// Open writable connection for setup
|
||||
rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = rw.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT, role TEXT,
|
||||
lat REAL, lon REAL,
|
||||
last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0,
|
||||
battery_mv INTEGER, temperature_c REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observers (
|
||||
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
name TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL, rssi REAL, score INTEGER,
|
||||
path_json TEXT, timestamp TEXT
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Open read-only handle for the store
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Close()
|
||||
rw.Close()
|
||||
})
|
||||
|
||||
return db, rw
|
||||
}
|
||||
|
||||
// TestEnrichObsFallbackToDB verifies that enrichObs falls back to the DB when
|
||||
// the parent transmission has been evicted from memory (#871 root cause).
|
||||
func TestEnrichObsFallbackToDB(t *testing.T) {
|
||||
db, rw := setupTestDB871(t)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := rw.Exec(
|
||||
`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type, decoded_json) VALUES (?, ?, ?, ?, ?)`,
|
||||
"aabbcc", "abc123", now, 4, `{"pubKey":"pk1"}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{})
|
||||
|
||||
// Observation references tx_id=1, but tx is NOT in byTxID (simulates eviction)
|
||||
obs := &StoreObs{
|
||||
ID: 1,
|
||||
TransmissionID: 1,
|
||||
ObserverID: "obs1",
|
||||
ObserverName: "Observer1",
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
result := store.enrichObs(obs)
|
||||
|
||||
// hash must be present from DB fallback
|
||||
if result["hash"] == nil {
|
||||
t.Errorf("enrichObs: hash is nil — DB fallback failed")
|
||||
}
|
||||
if h, ok := result["hash"].(string); !ok || h != "abc123" {
|
||||
t.Errorf("enrichObs: expected hash 'abc123', got %v", result["hash"])
|
||||
}
|
||||
if result["payload_type"] == nil {
|
||||
t.Errorf("enrichObs: payload_type is nil — DB fallback failed")
|
||||
}
|
||||
|
||||
// When tx IS in memory, it should use the in-memory path
|
||||
pt := 4
|
||||
store.byTxID[1] = &StoreTx{
|
||||
ID: 1, Hash: "abc123", FirstSeen: now,
|
||||
PayloadType: &pt, RawHex: "aabbcc",
|
||||
}
|
||||
|
||||
result2 := store.enrichObs(obs)
|
||||
if result2["hash"] == nil {
|
||||
t.Errorf("enrichObs with in-memory tx: hash is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetNodeHealthRecentPacketsNoNilFields verifies that GetNodeHealth's
|
||||
// recentPackets never contains entries with nil hash or timestamp.
|
||||
func TestGetNodeHealthRecentPacketsNoNilFields(t *testing.T) {
|
||||
db, rw := setupTestDB871(t)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := rw.Exec(
|
||||
`INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)`,
|
||||
"pk1", "TestNode", "repeater", now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{})
|
||||
|
||||
pt := 4
|
||||
tx := &StoreTx{
|
||||
ID: 1, Hash: "hash1", FirstSeen: now,
|
||||
PayloadType: &pt, DecodedJSON: `{"pubKey":"pk1"}`,
|
||||
obsKeys: make(map[string]bool), observerSet: make(map[string]bool),
|
||||
}
|
||||
store.byTxID[1] = tx
|
||||
store.byHash["hash1"] = tx
|
||||
store.byNode["pk1"] = []*StoreTx{tx}
|
||||
store.nodeHashes["pk1"] = map[string]bool{"hash1": true}
|
||||
|
||||
result, err := store.GetNodeHealth("pk1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("GetNodeHealth returned nil")
|
||||
}
|
||||
|
||||
packets, ok := result["recentPackets"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("recentPackets is not []map[string]interface{}")
|
||||
}
|
||||
|
||||
for i, p := range packets {
|
||||
if p["hash"] == nil {
|
||||
t.Errorf("recentPackets[%d] has nil hash", i)
|
||||
}
|
||||
if p["timestamp"] == nil {
|
||||
t.Errorf("recentPackets[%d] has nil timestamp", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichObsNilDB verifies enrichObs doesn't panic when db is nil.
|
||||
func TestEnrichObsNilDB(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
}
|
||||
|
||||
obs := &StoreObs{
|
||||
ID: 1, TransmissionID: 999,
|
||||
Timestamp: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
result := store.enrichObs(obs)
|
||||
if result["hash"] != nil {
|
||||
t.Errorf("expected nil hash when no DB and no in-memory tx, got %v", result["hash"])
|
||||
}
|
||||
}
|
||||
@@ -2083,7 +2083,7 @@ func (s *Server) handleObserverAnalytics(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
snrBuckets[bucket].Count++
|
||||
}
|
||||
if i < 20 {
|
||||
if i < 20 && enriched["hash"] != nil {
|
||||
recentPackets = append(recentPackets, enriched)
|
||||
}
|
||||
}
|
||||
@@ -2386,6 +2386,9 @@ func mapSliceToObservations(maps []map[string]interface{}) []ObservationResp {
|
||||
obs.SNR = m["snr"]
|
||||
obs.RSSI = m["rssi"]
|
||||
obs.PathJSON = m["path_json"]
|
||||
obs.ResolvedPath = m["resolved_path"]
|
||||
obs.Direction = m["direction"]
|
||||
obs.RawHex = m["raw_hex"]
|
||||
obs.Timestamp = m["timestamp"]
|
||||
result = append(result, obs)
|
||||
}
|
||||
|
||||
@@ -2412,6 +2412,31 @@ func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
|
||||
m["payload_type"] = intPtrOrNil(tx.PayloadType)
|
||||
m["route_type"] = intPtrOrNil(tx.RouteType)
|
||||
m["decoded_json"] = strOrNil(tx.DecodedJSON)
|
||||
} else {
|
||||
// Parent tx was evicted from memory — fall back to DB lookup so that
|
||||
// hash/timestamp are always present in the response (root cause of #857).
|
||||
if s.db != nil {
|
||||
if row, err := s.db.GetTransmissionByID(obs.TransmissionID); err == nil && row != nil {
|
||||
if h, ok := row["hash"]; ok {
|
||||
m["hash"] = h
|
||||
}
|
||||
if ts, ok := row["first_seen"]; ok && m["timestamp"] == nil {
|
||||
m["timestamp"] = ts
|
||||
}
|
||||
if rh, ok := row["raw_hex"]; ok {
|
||||
m["raw_hex"] = rh
|
||||
}
|
||||
if pt, ok := row["payload_type"]; ok {
|
||||
m["payload_type"] = pt
|
||||
}
|
||||
if rt, ok := row["route_type"]; ok {
|
||||
m["route_type"] = rt
|
||||
}
|
||||
if dj, ok := row["decoded_json"]; ok {
|
||||
m["decoded_json"] = dj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
@@ -6580,6 +6605,10 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro
|
||||
for i := len(packets) - 1; i >= len(packets)-recentLimit; i-- {
|
||||
p := s.txToMapWithRP(packets[i])
|
||||
delete(p, "observations")
|
||||
// Defensive: skip packets missing hash or timestamp (belt-and-suspenders for #857)
|
||||
if p["hash"] == nil || p["timestamp"] == nil {
|
||||
continue
|
||||
}
|
||||
recentPackets = append(recentPackets, p)
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,9 @@ type ObservationResp struct {
|
||||
SNR interface{} `json:"snr"`
|
||||
RSSI interface{} `json:"rssi"`
|
||||
PathJSON interface{} `json:"path_json"`
|
||||
ResolvedPath interface{} `json:"resolved_path,omitempty"`
|
||||
Direction interface{} `json:"direction,omitempty"`
|
||||
RawHex interface{} `json:"raw_hex,omitempty"`
|
||||
Timestamp interface{} `json:"timestamp"`
|
||||
}
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
const obs = data.observations.find(o => String(o.id) === String(obsTarget));
|
||||
if (obs) {
|
||||
expandedHashes.add(h);
|
||||
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, resolved_path: obs.resolved_path, timestamp: obs.timestamp, first_seen: obs.timestamp};
|
||||
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, resolved_path: obs.resolved_path, direction: obs.direction, timestamp: obs.timestamp, first_seen: obs.timestamp};
|
||||
clearParsedCache(obsPacket);
|
||||
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
|
||||
} else {
|
||||
@@ -1246,7 +1246,7 @@
|
||||
const child = group?._children?.find(c => String(c.id) === String(value));
|
||||
if (child) {
|
||||
const parentData = group._fetchedData;
|
||||
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, resolved_path: child.resolved_path, timestamp: child.timestamp, first_seen: child.timestamp} : child;
|
||||
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, resolved_path: child.resolved_path, direction: child.direction, timestamp: child.timestamp, first_seen: child.timestamp} : child;
|
||||
if (parentData) { clearParsedCache(obsPacket); }
|
||||
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
|
||||
}
|
||||
@@ -1797,7 +1797,7 @@
|
||||
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML;
|
||||
const content = document.createElement('div');
|
||||
panel.appendChild(content);
|
||||
await renderDetail(content, data);
|
||||
await renderDetail(content, data, selectedObservationId);
|
||||
if (!isMobileNow) initPanelResize();
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
|
||||
@@ -6116,6 +6116,144 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ==='
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Issue #866: Full-page obs-switch — hex + path must update per observation =====
|
||||
{
|
||||
console.log('\n=== Issue #866: Full-page observation switch ===');
|
||||
|
||||
const ctx866 = makeSandbox();
|
||||
loadInCtx(ctx866, 'public/roles.js');
|
||||
loadInCtx(ctx866, 'public/app.js');
|
||||
loadInCtx(ctx866, 'public/packet-helpers.js');
|
||||
|
||||
test('#866: switching observation updates effectivePkt path_json', () => {
|
||||
const pkt = { id: 1, hash: 'abc123', observer_id: 'obs-agg', path_json: '["A","B","C","D"]', raw_hex: '0484A1B1C1D1', route_type: 1, timestamp: '2026-01-01T00:00:00Z' };
|
||||
const obs1 = { id: 10, observer_id: 'obs-1', path_json: '["A","B"]', snr: 5, rssi: -80, timestamp: '2026-01-01T00:01:00Z' };
|
||||
const obs2 = { id: 20, observer_id: 'obs-2', path_json: '["A","B","C","D"]', snr: 8, rssi: -75, timestamp: '2026-01-01T00:02:00Z' };
|
||||
|
||||
// Simulate renderDetail logic: pick obs1
|
||||
const eff1 = ctx866.clearParsedCache({...pkt, ...obs1, _isObservation: true});
|
||||
const path1 = ctx866.getParsedPath(eff1);
|
||||
assert.deepStrictEqual(path1, ['A', 'B']);
|
||||
assert.strictEqual(eff1.observer_id, 'obs-1');
|
||||
assert.strictEqual(eff1.snr, 5);
|
||||
|
||||
// Switch to obs2
|
||||
const eff2 = ctx866.clearParsedCache({...pkt, ...obs2, _isObservation: true});
|
||||
const path2 = ctx866.getParsedPath(eff2);
|
||||
assert.deepStrictEqual(path2, ['A', 'B', 'C', 'D']);
|
||||
assert.strictEqual(eff2.observer_id, 'obs-2');
|
||||
assert.strictEqual(eff2.snr, 8);
|
||||
});
|
||||
|
||||
test('#866: effectivePkt preserves raw_hex from packet when obs has none', () => {
|
||||
const pkt = { id: 1, hash: 'h1', raw_hex: '0482AABB', route_type: 1 };
|
||||
const obs = { id: 10, observer_id: 'obs-1', path_json: '["AA"]', snr: 3, rssi: -90, timestamp: '2026-01-01T00:00:00Z' };
|
||||
const eff = ctx866.clearParsedCache({...pkt, ...obs, _isObservation: true});
|
||||
// obs doesn't have raw_hex, so packet's raw_hex survives spread
|
||||
assert.strictEqual(eff.raw_hex, '0482AABB');
|
||||
});
|
||||
|
||||
test('#866: effectivePkt uses obs raw_hex when available (API now returns it)', () => {
|
||||
const pkt = { id: 1, hash: 'h1', raw_hex: '0482AABB', route_type: 1 };
|
||||
const obs = { id: 10, observer_id: 'obs-1', raw_hex: '0441CC', path_json: '["CC"]', snr: 3, rssi: -90, timestamp: '2026-01-01T00:00:00Z' };
|
||||
const eff = ctx866.clearParsedCache({...pkt, ...obs, _isObservation: true});
|
||||
// obs has raw_hex from API, should override
|
||||
assert.strictEqual(eff.raw_hex, '0441CC');
|
||||
});
|
||||
|
||||
test('#866: direction field carried through observation spread', () => {
|
||||
const pkt = { id: 1, hash: 'h1', direction: 'rx', route_type: 1 };
|
||||
const obs = { id: 10, observer_id: 'obs-1', direction: 'tx', path_json: '[]', timestamp: '2026-01-01T00:00:00Z' };
|
||||
const eff = {...pkt, ...obs, _isObservation: true};
|
||||
assert.strictEqual(eff.direction, 'tx');
|
||||
});
|
||||
|
||||
test('#866: resolved_path carried through observation spread', () => {
|
||||
const pkt = { id: 1, hash: 'h1', resolved_path: '["aaa","bbb","ccc"]', route_type: 1 };
|
||||
const obs = { id: 10, observer_id: 'obs-1', resolved_path: '["aaa"]', path_json: '["AA"]', timestamp: '2026-01-01T00:00:00Z' };
|
||||
const eff = ctx866.clearParsedCache({...pkt, ...obs, _isObservation: true});
|
||||
const rp = ctx866.getResolvedPath(eff);
|
||||
assert.deepStrictEqual(rp, ['aaa']);
|
||||
});
|
||||
|
||||
test('#866: getPathLenOffset used for hop count cross-check', () => {
|
||||
// Flood route: offset 1
|
||||
assert.strictEqual(ctx866.getPathLenOffset(1), 1);
|
||||
assert.strictEqual(ctx866.getPathLenOffset(2), 1);
|
||||
// Transport route: offset 5
|
||||
assert.strictEqual(ctx866.getPathLenOffset(0), 5);
|
||||
assert.strictEqual(ctx866.getPathLenOffset(3), 5);
|
||||
});
|
||||
|
||||
test('#866: URL hash should encode obs parameter for deep linking', () => {
|
||||
// Simulate the URL construction pattern from renderDetail obs click
|
||||
const pktHash = 'abc123def456';
|
||||
const obsId = '42';
|
||||
const url = `#/packets/${pktHash}?obs=${obsId}`;
|
||||
assert.strictEqual(url, '#/packets/abc123def456?obs=42');
|
||||
// Parse back
|
||||
const qIdx = url.indexOf('?');
|
||||
const qs = new URLSearchParams(url.substring(qIdx));
|
||||
assert.strictEqual(qs.get('obs'), '42');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== #872 — hop-display unreliable badge =====
|
||||
{
|
||||
console.log('\n--- #872: hop-display unreliable warning badge ---');
|
||||
|
||||
function makeHopDisplaySandbox() {
|
||||
const sb = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
},
|
||||
console,
|
||||
Date, Math, Array, Object, String, Number, JSON, RegExp, Map, Set,
|
||||
encodeURIComponent, parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
|
||||
setTimeout: () => {}, setInterval: () => {}, clearTimeout: () => {}, clearInterval: () => {},
|
||||
};
|
||||
sb.window.document = sb.document;
|
||||
sb.self = sb.window;
|
||||
sb.globalThis = sb.window;
|
||||
const ctx = vm.createContext(sb);
|
||||
const hopSrc = fs.readFileSync(__dirname + '/public/hop-display.js', 'utf8');
|
||||
vm.runInContext(hopSrc, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const hopCtx = makeHopDisplaySandbox();
|
||||
|
||||
test('#872: unreliable hop renders warning badge, not strikethrough', () => {
|
||||
const html = hopCtx.window.HopDisplay.renderHop('AABB', {
|
||||
name: 'TestNode', pubkey: 'pk123', unreliable: true,
|
||||
ambiguous: false, conflicts: [], globalFallback: false,
|
||||
}, {});
|
||||
// Must contain unreliable warning badge button
|
||||
assert.ok(html.includes('hop-unreliable-btn'), 'should have unreliable badge button');
|
||||
assert.ok(html.includes('⚠️'), 'should have ⚠️ icon');
|
||||
assert.ok(html.includes('Unreliable name resolution'), 'should have tooltip text');
|
||||
// Must NOT contain line-through in inline style (CSS class no longer has it)
|
||||
assert.ok(!html.includes('line-through'), 'should not contain line-through');
|
||||
// Should still have hop-unreliable class for subtle styling
|
||||
assert.ok(html.includes('hop-unreliable'), 'should have hop-unreliable class');
|
||||
});
|
||||
|
||||
test('#872: reliable hop does NOT render unreliable badge', () => {
|
||||
const html = hopCtx.window.HopDisplay.renderHop('CCDD', {
|
||||
name: 'GoodNode', pubkey: 'pk456', unreliable: false,
|
||||
ambiguous: false, conflicts: [], globalFallback: false,
|
||||
}, {});
|
||||
assert.ok(!html.includes('hop-unreliable-btn'), 'should not have unreliable badge');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
Reference in New Issue
Block a user