mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-03 21:51:47 +00:00
0b35c7eef3
## Summary Follows the reconciliation recommendation in #916 — extracts only the NET-NEW persistence layer from that PR (which is now superseded by #1002 for the overlay UI) into a focused 6-file change against current master. **What this adds:** - `multibyte_sup_v1` migration: `multibyte_sup INTEGER NOT NULL DEFAULT 0` + `multibyte_evidence TEXT` on `nodes`/`inactive_nodes` so capability survives restart - `hasMultibyteSupCols` schema detection gates the persist/load paths - `loadMultibyteCapFromDB()`: pre-populates `mbCapSnapshot`/`mbCapIndex` at startup — cold starts serve last-known capability without waiting for the first ~15s analytics cycle - `maybePersistMultibyteCapability()` + `persistMultibyteCapability()`: after each analytics cycle; TryLock-gated (concurrent cycles coalesce); skips `sup==0` entries (data-destruction guard) - `GetMultibyteCapFor(pk)`: O(1) map lookup; both `handleNodes` and node-detail call sites updated from the O(N)-alloc `GetMultiByteCapMap()` **What this explicitly does NOT change:** - API field names (`multi_byte_status`, `multi_byte_evidence`, `multi_byte_max_hash_size`) - `EnrichNodeWithMultiByte` — unchanged - `GetMultiByteCapMap` — still present for any external callers - `public/map.js`, `public/live.css`, `Dockerfile`, `docs/` — zero frontend churn ## Test plan - [x] `TestMultibyteCapPersistRoundTrip` — confirmed values survive persist → fresh-store load - [x] `TestMultibyteCapPersistSkipsUnknown` — data-destruction guard: `sup==0` entry does not overwrite DB-confirmed value - [x] `TestMultibyteCapMaybePersistCoalesces` — TryLock coalesces 10 concurrent callers without deadlock - [x] `TestMultibyteCapGetMultibyteCapForO1` — O(1) index returns correct entry / false for unknown pubkey - [x] `TestMultibyteCapLoadFromDB` — only `sup>0` rows loaded; `sup==0` row excluded - [x] `TestSchemaMultibyteSupColumns` — migration adds columns to both tables; idempotent on second `OpenStore` - [x] All existing `TestMultiByteCapability_*` tests pass unchanged - [x] Full ingestor test suite: `ok` in 27s - [x] `go build ./cmd/server/ && go build ./cmd/ingestor/` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: openclaw-bot <bot@openclaw>
531 lines
18 KiB
Go
531 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// recentTS returns a timestamp string N hours ago, ensuring test data
|
|
// stays within the 7-day advert window used by computeNodeHashSizeInfo.
|
|
func recentTS(hoursAgo int) string {
|
|
return time.Now().UTC().Add(-time.Duration(hoursAgo) * time.Hour).Format("2006-01-02T15:04:05.000Z")
|
|
}
|
|
|
|
// setupCapabilityTestDB creates a minimal in-memory DB with nodes table.
|
|
func setupCapabilityTestDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
conn.Exec(`CREATE TABLE 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
|
|
)`)
|
|
conn.Exec(`CREATE TABLE observers (
|
|
id TEXT PRIMARY KEY, name TEXT, iata TEXT, last_seen TEXT,
|
|
first_seen TEXT, packet_count INTEGER DEFAULT 0, model TEXT,
|
|
firmware TEXT, client_version TEXT, radio TEXT, battery_mv INTEGER,
|
|
uptime_secs INTEGER
|
|
)`)
|
|
return &DB{conn: conn}
|
|
}
|
|
|
|
// addTestPacket adds a StoreTx to the store's internal structures including
|
|
// the byPathHop index and byPayloadType index.
|
|
func addTestPacket(store *PacketStore, tx *StoreTx) {
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
tx.ID = len(store.packets) + 1
|
|
if tx.Hash == "" {
|
|
tx.Hash = fmt.Sprintf("test-hash-%d", tx.ID)
|
|
}
|
|
store.packets = append(store.packets, tx)
|
|
store.byHash[tx.Hash] = tx
|
|
store.byTxID[tx.ID] = tx
|
|
if tx.PayloadType != nil {
|
|
store.byPayloadType[*tx.PayloadType] = append(store.byPayloadType[*tx.PayloadType], tx)
|
|
}
|
|
addTxToPathHopIndex(store.byPathHop, tx)
|
|
}
|
|
|
|
// buildPathByte returns a 2-char hex string for the path byte with given
|
|
// hashSize (1-3) and hopCount.
|
|
func buildPathByte(hashSize, hopCount int) string {
|
|
b := byte(((hashSize - 1) & 0x3) << 6) | byte(hopCount&0x3F)
|
|
return fmt.Sprintf("%02x", b)
|
|
}
|
|
|
|
// makeTestAdvert creates a StoreTx representing a flood advert packet.
|
|
func makeTestAdvert(pubkey string, hashSize int) *StoreTx {
|
|
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": pubkey, "name": pubkey[:8]})
|
|
pt := 4
|
|
pathByte := buildPathByte(hashSize, 1)
|
|
prefix := strings.ToLower(pubkey[:hashSize*2])
|
|
rawHex := "01" + pathByte + prefix // flood header + path byte + hop prefix
|
|
return &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
DecodedJSON: string(decoded),
|
|
PathJSON: `["` + prefix + `"]`,
|
|
FirstSeen: recentTS(24),
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_Confirmed tests that a repeater advertising
|
|
// with hash_size >= 2 is classified as "confirmed".
|
|
func TestMultiByteCapability_Confirmed(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepA", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "confirmed" {
|
|
t.Errorf("expected confirmed, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].Evidence != "advert" {
|
|
t.Errorf("expected advert evidence, got %s", caps[0].Evidence)
|
|
}
|
|
if caps[0].MaxHashSize != 2 {
|
|
t.Errorf("expected maxHashSize 2, got %d", caps[0].MaxHashSize)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_Suspected tests that a repeater whose prefix
|
|
// appears in a multi-byte path is classified as "suspected".
|
|
func TestMultiByteCapability_Suspected(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepB", "repeater", recentTS(48))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Non-advert packet with 2-byte hash in path, hop prefix matching node
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "suspected" {
|
|
t.Errorf("expected suspected, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].Evidence != "path" {
|
|
t.Errorf("expected path evidence, got %s", caps[0].Evidence)
|
|
}
|
|
if caps[0].MaxHashSize != 2 {
|
|
t.Errorf("expected maxHashSize 2, got %d", caps[0].MaxHashSize)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_Unknown tests that a repeater with only 1-byte
|
|
// adverts and no multi-byte path appearances is classified as "unknown".
|
|
func TestMultiByteCapability_Unknown(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepC", "repeater", recentTS(72))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Advert with 1-byte hash only
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 1))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "unknown" {
|
|
t.Errorf("expected unknown, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].MaxHashSize != 1 {
|
|
t.Errorf("expected maxHashSize 1, got %d", caps[0].MaxHashSize)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_PrefixCollision tests that when two repeaters
|
|
// share the same prefix, one confirmed via advert, the other gets
|
|
// suspected (not confirmed) from path data alone.
|
|
func TestMultiByteCapability_PrefixCollision(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
// Two repeaters sharing 1-byte prefix "aa"
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabb000000000001", "RepConfirmed", "repeater", recentTS(24))
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aacc000000000002", "RepOther", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// RepConfirmed has a 2-byte advert
|
|
addTestPacket(store, makeTestAdvert("aabb000000000001", 2))
|
|
|
|
// A packet with 2-byte path containing 1-byte hop "aa" — both share this prefix
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aa"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aa"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(caps))
|
|
}
|
|
|
|
capByName := map[string]MultiByteCapEntry{}
|
|
for _, c := range caps {
|
|
capByName[c.Name] = c
|
|
}
|
|
|
|
if capByName["RepConfirmed"].Status != "confirmed" {
|
|
t.Errorf("RepConfirmed expected confirmed, got %s", capByName["RepConfirmed"].Status)
|
|
}
|
|
if capByName["RepOther"].Status != "suspected" {
|
|
t.Errorf("RepOther expected suspected, got %s", capByName["RepOther"].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_TraceExcluded tests that TRACE packets (payload_type 8)
|
|
// do NOT contribute to "suspected" multi-byte capability. TRACE packets carry
|
|
// hash size in their own flags, so pre-1.14 repeaters can forward multi-byte
|
|
// TRACEs without actually supporting multi-byte hashes. See #714.
|
|
func TestMultiByteCapability_TraceExcluded(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepTrace", "repeater", recentTS(48))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// TRACE packet (payload_type 8) with 2-byte hash in path
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 8
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "unknown" {
|
|
t.Errorf("expected unknown (TRACE excluded), got %s", caps[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_NonTraceStillSuspected verifies that non-TRACE packets
|
|
// with 2-byte paths still correctly mark a repeater as "suspected".
|
|
func TestMultiByteCapability_NonTraceStillSuspected(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepNonTrace", "repeater", recentTS(48))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// GRP_TXT packet (payload_type 1) with 2-byte hash in path
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "suspected" {
|
|
t.Errorf("expected suspected, got %s", caps[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion verifies that
|
|
// "confirmed" status from adverts is not affected by the TRACE exclusion.
|
|
func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepConfirmedTrace", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Advert with 2-byte hash (confirms capability)
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
|
|
|
// TRACE packet also present — should not downgrade confirmed status
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 8
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "confirmed" {
|
|
t.Errorf("expected confirmed (unaffected by TRACE), got %s", caps[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_CompanionConfirmed tests that a companion with
|
|
// multi-byte advert is classified as "confirmed", not "unknown" (Bug 1, #754).
|
|
func TestMultiByteCapability_CompanionConfirmed(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "CompA", "companion", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "confirmed" {
|
|
t.Errorf("expected confirmed for companion, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].Role != "companion" {
|
|
t.Errorf("expected role companion, got %s", caps[0].Role)
|
|
}
|
|
if caps[0].Evidence != "advert" {
|
|
t.Errorf("expected advert evidence, got %s", caps[0].Evidence)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_RoleColumnPopulated tests that the Role field is
|
|
// populated for all node types (Bug 2, #754).
|
|
func TestMultiByteCapability_RoleColumnPopulated(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabb000000000001", "Rep1", "repeater", recentTS(24))
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"ccdd000000000002", "Comp1", "companion", recentTS(24))
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"eeff000000000003", "Room1", "room_server", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
addTestPacket(store, makeTestAdvert("aabb000000000001", 2))
|
|
addTestPacket(store, makeTestAdvert("ccdd000000000002", 2))
|
|
addTestPacket(store, makeTestAdvert("eeff000000000003", 1))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 3 {
|
|
t.Fatalf("expected 3 entries, got %d", len(caps))
|
|
}
|
|
|
|
roleByName := map[string]string{}
|
|
for _, c := range caps {
|
|
roleByName[c.Name] = c.Role
|
|
}
|
|
if roleByName["Rep1"] != "repeater" {
|
|
t.Errorf("Rep1 role: expected repeater, got %s", roleByName["Rep1"])
|
|
}
|
|
if roleByName["Comp1"] != "companion" {
|
|
t.Errorf("Comp1 role: expected companion, got %s", roleByName["Comp1"])
|
|
}
|
|
if roleByName["Room1"] != "room_server" {
|
|
t.Errorf("Room1 role: expected room_server, got %s", roleByName["Room1"])
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_AdopterEvidenceTakesPrecedence tests that when
|
|
// adopter data shows hashSize >= 2 but path evidence says "suspected",
|
|
// the node is upgraded to "confirmed" (Bug 3, #754).
|
|
func TestMultiByteCapability_AdopterEvidenceTakesPrecedence(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepAdopter", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Only a path-based packet (no advert) — would normally be "suspected"
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
// Without adopter data: should be suspected
|
|
caps := store.computeMultiByteCapability(nil)
|
|
capByName := map[string]MultiByteCapEntry{}
|
|
for _, c := range caps {
|
|
capByName[c.Name] = c
|
|
}
|
|
if capByName["RepAdopter"].Status != "suspected" {
|
|
t.Errorf("without adopter data: expected suspected, got %s", capByName["RepAdopter"].Status)
|
|
}
|
|
|
|
// With adopter data showing hashSize 2: should be confirmed
|
|
adopterHS := map[string]int{"aabbccdd11223344": 2}
|
|
caps = store.computeMultiByteCapability(adopterHS)
|
|
capByName = map[string]MultiByteCapEntry{}
|
|
for _, c := range caps {
|
|
capByName[c.Name] = c
|
|
}
|
|
if capByName["RepAdopter"].Status != "confirmed" {
|
|
t.Errorf("with adopter data: expected confirmed, got %s", capByName["RepAdopter"].Status)
|
|
}
|
|
if capByName["RepAdopter"].Evidence != "advert" {
|
|
t.Errorf("with adopter data: expected advert evidence, got %s", capByName["RepAdopter"].Evidence)
|
|
}
|
|
}
|
|
|
|
// --- Persistence layer tests (#903, relocated #1324 follow-up) ---
|
|
//
|
|
// The actual DB persistence now lives in cmd/ingestor (see
|
|
// cmd/ingestor/multibyte_persist_test.go). What the server is responsible
|
|
// for is publishing the snapshot file that the ingestor consumes. The
|
|
// data-destruction guard ("never overwrite confirmed with unknown") is
|
|
// enforced by the ingestor, not the server — the snapshot can legitimately
|
|
// carry "unknown" entries; the ingestor filters them.
|
|
|
|
// setupPersistTestDB creates an in-memory DB with multibyte_sup/multibyte_evidence columns.
|
|
func setupPersistTestDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
conn.Exec(`CREATE TABLE 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,
|
|
foreign_advert INTEGER DEFAULT 0, default_scope TEXT,
|
|
multibyte_sup INTEGER NOT NULL DEFAULT 0, multibyte_evidence TEXT
|
|
)`)
|
|
conn.Exec(`CREATE TABLE inactive_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,
|
|
foreign_advert INTEGER DEFAULT 0, default_scope TEXT,
|
|
multibyte_sup INTEGER NOT NULL DEFAULT 0, multibyte_evidence TEXT
|
|
)`)
|
|
return &DB{conn: conn, hasMultibyteSupCols: true}
|
|
}
|
|
|
|
// TestMultibyteCapGetMultibyteCapForO1 verifies that GetMultibyteCapFor returns
|
|
// the correct entry via the O(1) mbCapIndex map.
|
|
func TestMultibyteCapGetMultibyteCapForO1(t *testing.T) {
|
|
db := setupPersistTestDB(t)
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Directly populate the index as the analytics cycle would.
|
|
store.cacheMu.Lock()
|
|
store.mbCapIndex = map[string]MultiByteCapEntry{
|
|
"aabbccdd11223344": {PublicKey: "aabbccdd11223344", Status: "confirmed", Evidence: "advert"},
|
|
"eeff001122334455": {PublicKey: "eeff001122334455", Status: "suspected", Evidence: "path"},
|
|
}
|
|
store.cacheMu.Unlock()
|
|
|
|
e, ok := store.GetMultibyteCapFor("aabbccdd11223344")
|
|
if !ok || e == nil {
|
|
t.Fatal("expected entry for known pubkey, got none")
|
|
}
|
|
if e.Status != "confirmed" {
|
|
t.Errorf("status = %q, want confirmed", e.Status)
|
|
}
|
|
|
|
_, ok = store.GetMultibyteCapFor("0000000000000000")
|
|
if ok {
|
|
t.Error("expected no entry for unknown pubkey")
|
|
}
|
|
}
|
|
|
|
// TestMultibyteCapLoadFromDB verifies that loadMultibyteCapFromDB skips nodes
|
|
// with multibyte_sup == 0 and only loads confirmed/suspected entries.
|
|
func TestMultibyteCapLoadFromDB(t *testing.T) {
|
|
db := setupPersistTestDB(t)
|
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
|
VALUES ('aa11', 'A', 'repeater', '2026-01-01T00:00:00Z', 2, 'advert')`)
|
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
|
|
VALUES ('bb22', 'B', 'repeater', '2026-01-01T00:00:00Z', 1, 'path')`)
|
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup)
|
|
VALUES ('cc33', 'C', 'repeater', '2026-01-01T00:00:00Z', 0)`) // unknown — must be skipped
|
|
|
|
store := NewPacketStore(db, nil)
|
|
store.loadMultibyteCapFromDB()
|
|
|
|
store.cacheMu.Lock()
|
|
snap := store.mbCapSnapshot
|
|
idx := store.mbCapIndex
|
|
store.cacheMu.Unlock()
|
|
|
|
if len(snap) != 2 {
|
|
t.Fatalf("expected 2 entries (confirmed+suspected), got %d", len(snap))
|
|
}
|
|
if e, ok := idx["aa11"]; !ok || e.Status != "confirmed" {
|
|
t.Errorf("aa11: expected confirmed, got %+v", e)
|
|
}
|
|
if e, ok := idx["bb22"]; !ok || e.Status != "suspected" {
|
|
t.Errorf("bb22: expected suspected, got %+v", e)
|
|
}
|
|
if _, ok := idx["cc33"]; ok {
|
|
t.Error("cc33 with sup=0 should not be in the index")
|
|
}
|
|
}
|