mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 01:07:53 +00:00
For senders who never share a literal GPS fix, resolve the session's path[0] entry-point prefix to a known repeater position (only when it resolves unambiguously, same unique_prefix discipline as /api/resolve-hops) and label it with the most specific configured area — shown as a badge in the Sessions table, clearly marked approximate since it's the repeater's position, not the sender's own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
776 lines
31 KiB
Go
776 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/lora"
|
|
)
|
|
|
|
// TestHandleWardrivingStats covers the three sections item-by-item:
|
|
// activity (total + top senders), entry points (path[0] tally), and
|
|
// observer coverage (joined against the static iataCoords table).
|
|
func TestHandleWardrivingStats(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
|
|
t.Fatalf("clear observations: %v", err)
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
// Two senders, three messages: Alice sends 2, Bob sends 1.
|
|
insertTx := func(hash, decodedJSON string) int64 {
|
|
res, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
"aa", hash, now, decodedJSON,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert tx %s: %v", hash, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
tx1 := insertTx("wd1", `{"sender":"Alice","text":"Alice: MM:abc123"}`)
|
|
tx2 := insertTx("wd2", `{"sender":"Alice","text":"Alice: MM:def456"}`)
|
|
tx3 := insertTx("wd3", `{"sender":"Bob","text":"Bob: MM:ghi789"}`)
|
|
|
|
// A non-wardriving channel message must never leak into the results.
|
|
insertTx2 := func(hash, channel string) {
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,?,?)`,
|
|
"aa", hash, now, channel, `{"sender":"Eve","text":"Eve: hi"}`,
|
|
); err != nil {
|
|
t.Fatalf("insert other-channel tx: %v", err)
|
|
}
|
|
}
|
|
insertTx2("other1", "#test")
|
|
|
|
// Seed observers: one with a known IATA (coordinates resolvable), one without.
|
|
// Schema is v3 (observations.observer_idx references observers.rowid, NOT
|
|
// the TEXT id column) — capture each insert's rowid via LastInsertId.
|
|
insertObserver := func(id, name, iata string) int64 {
|
|
res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, id, name, iata)
|
|
if err != nil {
|
|
t.Fatalf("insert observer %s: %v", id, err)
|
|
}
|
|
rowid, _ := res.LastInsertId()
|
|
return rowid
|
|
}
|
|
seaIdx := insertObserver("obsSEA", "SeattleObs", "SEA")
|
|
zzzIdx := insertObserver("obsXXX", "UnknownObs", "ZZZ")
|
|
|
|
insertObs := func(txID int64, observerIdx int64, pathJSON string, snr, rssi float64) {
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
|
|
txID, observerIdx, snr, rssi, pathJSON, time.Now().Unix(),
|
|
); err != nil {
|
|
t.Fatalf("insert observation: %v", err)
|
|
}
|
|
}
|
|
// tx1: two observations, both via entry prefix "AAAA", one from each observer.
|
|
// Signal values are distinct so the avg-SNR/avg-RSSI math is verifiable:
|
|
// avg SNR = (2+4+6+8)/4 = 5.0, avg RSSI = (-80-100-70-60)/4 = -77.5.
|
|
insertObs(tx1, seaIdx, `["AAAA","1111"]`, 2.0, -80)
|
|
insertObs(tx1, zzzIdx, `["AAAA","2222"]`, 4.0, -100)
|
|
// tx2: entry prefix "BBBB", heard only by SEA.
|
|
insertObs(tx2, seaIdx, `["BBBB"]`, 6.0, -70)
|
|
// tx3: entry prefix "AAAA" again (same prefix as tx1 — tallies together), heard by SEA.
|
|
insertObs(tx3, seaIdx, `["AAAA","3333"]`, 8.0, -60)
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
|
|
if resp.Channel != "#wardriving" {
|
|
t.Errorf("Channel = %q, want #wardriving", resp.Channel)
|
|
}
|
|
if resp.TotalMessages != 3 {
|
|
t.Errorf("TotalMessages = %d, want 3 (the #test message must not count)", resp.TotalMessages)
|
|
}
|
|
|
|
// Top senders: Alice (2) before Bob (1).
|
|
if len(resp.TopSenders) != 2 {
|
|
t.Fatalf("TopSenders = %+v, want 2 entries", resp.TopSenders)
|
|
}
|
|
if resp.TopSenders[0].Sender != "Alice" || resp.TopSenders[0].Count != 2 {
|
|
t.Errorf("TopSenders[0] = %+v, want {Alice 2}", resp.TopSenders[0])
|
|
}
|
|
if resp.TopSenders[1].Sender != "Bob" || resp.TopSenders[1].Count != 1 {
|
|
t.Errorf("TopSenders[1] = %+v, want {Bob 1}", resp.TopSenders[1])
|
|
}
|
|
|
|
// Entry points: "AAAA" appears in 3 observations (tx1 x2 + tx3 x1) across
|
|
// 2 distinct messages (tx1, tx3); "BBBB" appears in 1 observation, 1 message.
|
|
if len(resp.EntryPoints) != 2 {
|
|
t.Fatalf("EntryPoints = %+v, want 2 prefixes", resp.EntryPoints)
|
|
}
|
|
if resp.EntryPoints[0].Prefix != "AAAA" || resp.EntryPoints[0].ObservationCount != 3 || resp.EntryPoints[0].MessageCount != 2 {
|
|
t.Errorf("EntryPoints[0] = %+v, want {AAAA obs=3 msgs=2}", resp.EntryPoints[0])
|
|
}
|
|
if resp.EntryPoints[1].Prefix != "BBBB" || resp.EntryPoints[1].ObservationCount != 1 || resp.EntryPoints[1].MessageCount != 1 {
|
|
t.Errorf("EntryPoints[1] = %+v, want {BBBB obs=1 msgs=1}", resp.EntryPoints[1])
|
|
}
|
|
|
|
// Observer coverage: SEA heard 3 observations across all 3 messages;
|
|
// ZZZ heard 1 observation from 1 message. SEA's IATA resolves to real
|
|
// coordinates; ZZZ's unknown IATA leaves Lat/Lon nil.
|
|
if len(resp.Observers) != 2 {
|
|
t.Fatalf("Observers = %+v, want 2 entries", resp.Observers)
|
|
}
|
|
sea := resp.Observers[0]
|
|
if sea.ObserverName != "SeattleObs" || sea.ObservationCount != 3 || sea.MessageCount != 3 {
|
|
t.Errorf("Observers[0] = %+v, want {SeattleObs obs=3 msgs=3}", sea)
|
|
}
|
|
if sea.Lat == nil || sea.Lon == nil {
|
|
t.Error("SEA observer should resolve to known coordinates via iataCoords")
|
|
} else if *sea.Lat < 47 || *sea.Lat > 48 {
|
|
t.Errorf("SEA lat = %v, want ~47.45 (Seattle)", *sea.Lat)
|
|
}
|
|
zzz := resp.Observers[1]
|
|
if zzz.ObserverName != "UnknownObs" || zzz.ObservationCount != 1 {
|
|
t.Errorf("Observers[1] = %+v, want {UnknownObs obs=1}", zzz)
|
|
}
|
|
if zzz.Lat != nil || zzz.Lon != nil {
|
|
t.Errorf("ZZZ has an unrecognized IATA — Lat/Lon should stay nil, got lat=%v lon=%v", zzz.Lat, zzz.Lon)
|
|
}
|
|
|
|
// Time series should sum back to TotalMessages.
|
|
sum := 0
|
|
for _, pt := range resp.TimeSeries {
|
|
sum += pt.Count
|
|
}
|
|
if sum != 3 {
|
|
t.Errorf("TimeSeries sums to %d, want 3", sum)
|
|
}
|
|
|
|
// Signal quality: all 4 observations land in the same hourly bucket
|
|
// (inserted back-to-back "now"), so there's exactly one signal point
|
|
// averaging all 4 readings: avg SNR = 5.0, avg RSSI = -77.5.
|
|
if len(resp.SignalTimeSeries) != 1 {
|
|
t.Fatalf("SignalTimeSeries = %+v, want 1 bucket", resp.SignalTimeSeries)
|
|
}
|
|
sig := resp.SignalTimeSeries[0]
|
|
if sig.ObservationCount != 4 {
|
|
t.Errorf("SignalTimeSeries[0].ObservationCount = %d, want 4", sig.ObservationCount)
|
|
}
|
|
if sig.AvgSNR != 5.0 {
|
|
t.Errorf("SignalTimeSeries[0].AvgSNR = %v, want 5.0", sig.AvgSNR)
|
|
}
|
|
if sig.AvgRSSI != -77.5 {
|
|
t.Errorf("SignalTimeSeries[0].AvgRSSI = %v, want -77.5", sig.AvgRSSI)
|
|
}
|
|
if resp.AvgSNR == nil || *resp.AvgSNR != 5.0 {
|
|
t.Errorf("AvgSNR = %v, want 5.0", resp.AvgSNR)
|
|
}
|
|
if resp.AvgRSSI == nil || *resp.AvgRSSI != -77.5 {
|
|
t.Errorf("AvgRSSI = %v, want -77.5", resp.AvgRSSI)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_Sessions covers session/run grouping:
|
|
// consecutive messages within wardrivingSessionGapMinutes (15) from the
|
|
// same sender merge into one session; a bigger gap starts a new one.
|
|
func TestHandleWardrivingStats_Sessions(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
|
|
t.Fatalf("clear observations: %v", err)
|
|
}
|
|
|
|
base := time.Now().UTC().Add(-2 * time.Hour)
|
|
t0 := base // Alice, session A, msg 1
|
|
t1 := base.Add(5 * time.Minute) // Alice, session A, msg 2 (5min gap — same session)
|
|
t2 := base.Add(40 * time.Minute) // Alice, session B, msg 3 (35min gap from t1 — new session)
|
|
t3 := base.Add(10 * time.Minute) // Bob, single-message session
|
|
|
|
insertTx := func(hash, sender string, ts time.Time) int64 {
|
|
res, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
"aa", hash, ts.Format(time.RFC3339), `{"sender":"`+sender+`","text":"`+sender+`: MM:x"}`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert tx %s: %v", hash, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
tx0 := insertTx("s1", "Alice", t0)
|
|
tx1 := insertTx("s2", "Alice", t1)
|
|
tx2 := insertTx("s3", "Alice", t2)
|
|
tx3 := insertTx("s4", "Bob", t3)
|
|
|
|
insertObserver := func(id, name string) int64 {
|
|
res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name) VALUES (?,?)`, id, name)
|
|
if err != nil {
|
|
t.Fatalf("insert observer %s: %v", id, err)
|
|
}
|
|
rowid, _ := res.LastInsertId()
|
|
return rowid
|
|
}
|
|
o1 := insertObserver("obsO1", "ObsOne")
|
|
o2 := insertObserver("obsO2", "ObsTwo")
|
|
|
|
insertObs := func(txID, observerIdx int64, pathJSON string) {
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,1.0,-90,?,?)`,
|
|
txID, observerIdx, pathJSON, time.Now().Unix(),
|
|
); err != nil {
|
|
t.Fatalf("insert observation: %v", err)
|
|
}
|
|
}
|
|
insertObs(tx0, o1, `["EEEE"]`)
|
|
insertObs(tx1, o1, `["FFFF"]`)
|
|
insertObs(tx2, o2, `["EEEE"]`)
|
|
insertObs(tx3, o1, `["GGGG"]`)
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
|
|
if len(resp.Sessions) != 3 {
|
|
t.Fatalf("Sessions = %+v, want 3 (Alice session A, Alice session B, Bob session)", resp.Sessions)
|
|
}
|
|
|
|
// Ordered most-recent-first by StartTime: Alice-B (t2), Bob (t3), Alice-A (t0).
|
|
aliceB, bob, aliceA := resp.Sessions[0], resp.Sessions[1], resp.Sessions[2]
|
|
|
|
if aliceA.Sender != "Alice" || aliceA.MessageCount != 2 {
|
|
t.Errorf("Alice session A = %+v, want {Alice, 2 messages}", aliceA)
|
|
}
|
|
if aliceA.DurationMinutes < 4.9 || aliceA.DurationMinutes > 5.1 {
|
|
t.Errorf("Alice session A DurationMinutes = %v, want ~5.0", aliceA.DurationMinutes)
|
|
}
|
|
if aliceA.EntryPointCount != 2 {
|
|
t.Errorf("Alice session A EntryPointCount = %d, want 2 (EEEE, FFFF)", aliceA.EntryPointCount)
|
|
}
|
|
if aliceA.ObserverCount != 1 {
|
|
t.Errorf("Alice session A ObserverCount = %d, want 1 (only ObsOne heard it)", aliceA.ObserverCount)
|
|
}
|
|
|
|
if aliceB.Sender != "Alice" || aliceB.MessageCount != 1 {
|
|
t.Errorf("Alice session B = %+v, want {Alice, 1 message}", aliceB)
|
|
}
|
|
if aliceB.EntryPointCount != 1 || aliceB.ObserverCount != 1 {
|
|
t.Errorf("Alice session B = %+v, want {1 entry point, 1 observer}", aliceB)
|
|
}
|
|
|
|
if bob.Sender != "Bob" || bob.MessageCount != 1 {
|
|
t.Errorf("Bob session = %+v, want {Bob, 1 message}", bob)
|
|
}
|
|
|
|
// The 35-minute gap between t1 and t2 must NOT merge into one session —
|
|
// this is the core behavior under test.
|
|
if aliceA.MessageCount+aliceB.MessageCount != 3 {
|
|
t.Errorf("Alice's 3 messages should split into two sessions (2 + 1), got %d + %d", aliceA.MessageCount, aliceB.MessageCount)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_SessionAirtime covers the route-handler wiring
|
|
// for session airtime: handleWardrivingStats must call
|
|
// PacketStore.AirtimeForTransmissions with exactly that session's
|
|
// transmission IDs and attach the result as AirtimeMs. Uses a
|
|
// fully-controlled fake store (same helper as relay_airtime_share_test.go)
|
|
// swapped in for the real one, so the resolved-repeater counts are
|
|
// deterministic instead of depending on the store's async path resolver.
|
|
func TestHandleWardrivingStats_SessionAirtime(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
insertTx := func(hash string, ts time.Time, rawHex string) int64 {
|
|
res, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
rawHex, hash, ts.Format(time.RFC3339), `{"sender":"Alice","text":"Alice: MM:x"}`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert tx %s: %v", hash, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
// Two messages, one session (5 min apart, well under the 15min gap).
|
|
// 20-byte payload each; tx1 relayed by 3 distinct repeaters, tx2 by 2.
|
|
tx1 := insertTx("at1", now.Add(-10*time.Minute), strings.Repeat("ab", 20))
|
|
tx2 := insertTx("at2", now.Add(-5*time.Minute), strings.Repeat("ab", 20))
|
|
|
|
fakeStore := newRelayAirtimeShareTestStore([]*StoreTx{
|
|
makeRelayAirtimeTx(int(tx1), PayloadGRP_TXT, 20, 3, "at1"),
|
|
makeRelayAirtimeTx(int(tx2), PayloadGRP_TXT, 20, 2, "at2"),
|
|
})
|
|
fakeStore.addToResolvedPubkeyIndex(int(tx1), []string{"r1", "r2", "r3"})
|
|
fakeStore.addToResolvedPubkeyIndex(int(tx2), []string{"r1", "r2"})
|
|
srv.store = fakeStore
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
if len(resp.Sessions) != 1 {
|
|
t.Fatalf("Sessions = %+v, want 1 (both messages within the gap)", resp.Sessions)
|
|
}
|
|
|
|
preset := fakeStore.resolveLoRaPreset()
|
|
toa := lora.TimeOnAir(20, preset)
|
|
wantMs := (toa*3 + toa*2).Milliseconds()
|
|
|
|
sess := resp.Sessions[0]
|
|
if sess.AirtimeMs == nil {
|
|
t.Fatal("AirtimeMs is nil, want a populated value (store is available)")
|
|
}
|
|
if *sess.AirtimeMs != wantMs {
|
|
t.Errorf("AirtimeMs = %d, want %d", *sess.AirtimeMs, wantMs)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_SessionAirtime_DBOnlyMode confirms AirtimeMs is
|
|
// omitted (nil), not a misleading zero, when the in-memory store isn't
|
|
// available at all.
|
|
func TestHandleWardrivingStats_SessionAirtime_DBOnlyMode(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
srv.store = nil
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
"aa", "dbonly1", time.Now().UTC().Format(time.RFC3339), `{"sender":"Alice","text":"Alice: MM:x"}`,
|
|
); err != nil {
|
|
t.Fatalf("insert tx: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
if len(resp.Sessions) != 1 {
|
|
t.Fatalf("Sessions = %+v, want 1", resp.Sessions)
|
|
}
|
|
if resp.Sessions[0].AirtimeMs != nil {
|
|
t.Errorf("AirtimeMs = %v, want nil in DB-only mode", *resp.Sessions[0].AirtimeMs)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_GPSShares covers detection of senders who
|
|
// explicitly share their own position: a "<token>:<lat>,<lon>" suffix
|
|
// after the standard "MM:" token (confirmed empirically against live
|
|
// traffic — some wardriving clients append plaintext coordinates). Plain
|
|
// tokens, plain chat, and out-of-range coordinates must never register as
|
|
// a share.
|
|
func TestHandleWardrivingStats_GPSShares(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
|
|
// insertTx mirrors decodeGrpTxt's "<sender>: <message>" convention —
|
|
// text is sender-prefixed, and detectWardrivingGPSShares matches on
|
|
// that exact "<sender>: MM:" prefix.
|
|
insertTx := func(hash, sender, mmPayload string, tsOffset time.Duration) {
|
|
ts := time.Now().UTC().Add(tsOffset).Format(time.RFC3339)
|
|
text := sender + ": " + mmPayload
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
"aa", hash, ts, `{"sender":"`+sender+`","text":"`+text+`"}`,
|
|
); err != nil {
|
|
t.Fatalf("insert tx %s: %v", hash, err)
|
|
}
|
|
}
|
|
|
|
insertTx("std1", "Alice", "MM:c3e_zJ1rUA", -3*time.Hour) // standard token, no shared position
|
|
// Alice shares her position twice, moving — the more recent one should win.
|
|
insertTx("gps1", "Alice", "MM:c3e_zJ1rUA:55.10000,13.10000", -90*time.Minute)
|
|
insertTx("gps2", "Alice", "MM:c3e_zJ1rUA:55.20000,13.20000", -80*time.Minute)
|
|
insertTx("std2", "Bob", "MM:c3e_zJ1rUA", -2*time.Hour) // Bob never shares — must not appear
|
|
// Plain chat, and an out-of-range "coordinate" — neither is a real share.
|
|
insertTx("chat1", "Eve", "hey is anyone home", -60*time.Minute)
|
|
insertTx("bad1", "BadCoords", "MM:c3e_zJ1rUA:999.0,13.0", -50*time.Minute)
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
|
|
if len(resp.GPSShares) != 1 {
|
|
t.Fatalf("GPSShares = %+v, want 1 entry (only Alice actually shared a position)", resp.GPSShares)
|
|
}
|
|
share := resp.GPSShares[0]
|
|
if share.Sender != "Alice" || share.MessageCount != 2 {
|
|
t.Errorf("GPSShares[0] = %+v, want {Alice, 2 messages}", share)
|
|
}
|
|
if share.Lat != 55.2 || share.Lon != 13.2 {
|
|
t.Errorf("GPSShares[0] position = {%v, %v}, want the more recent {55.2, 13.2}", share.Lat, share.Lon)
|
|
}
|
|
for _, s := range resp.GPSShares {
|
|
if s.Sender == "Bob" || s.Sender == "Eve" || s.Sender == "BadCoords" {
|
|
t.Errorf("%s must not register as a GPS share (standard token / plain chat / out-of-range)", s.Sender)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_GPSShareArea confirms a shared position gets
|
|
// tagged with the most specific configured area (config.Areas), and is left
|
|
// unset when no area matches or none are configured.
|
|
func TestHandleWardrivingStats_GPSShareArea(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
f := func(v float64) *float64 { return &v }
|
|
srv.cfg.Areas = map[string]AreaEntry{
|
|
"DK": {Label: "Danmark (alle)", LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.25)},
|
|
"ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)},
|
|
}
|
|
|
|
insertTx := func(hash, sender, mmPayload string) {
|
|
ts := time.Now().UTC().Add(-30 * time.Minute).Format(time.RFC3339)
|
|
text := sender + ": " + mmPayload
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
"aa", hash, ts, `{"sender":"`+sender+`","text":"`+text+`"}`,
|
|
); err != nil {
|
|
t.Fatalf("insert tx %s: %v", hash, err)
|
|
}
|
|
}
|
|
insertTx("in-ode", "InOdense", "MM:c3e_zJ1rUA:55.4047,10.3810") // inside Odense (and DK)
|
|
insertTx("outside", "Elsewhere", "MM:c3e_zJ1rUA:40.0000,-74.0000") // outside every configured area
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
|
|
byShare := map[string]WardrivingGPSShare{}
|
|
for _, s := range resp.GPSShares {
|
|
byShare[s.Sender] = s
|
|
}
|
|
inOdense, ok := byShare["InOdense"]
|
|
if !ok {
|
|
t.Fatal("InOdense missing from GPSShares")
|
|
}
|
|
if inOdense.Area == nil || *inOdense.Area != "Odense by" {
|
|
t.Errorf("InOdense.Area = %v, want \"Odense by\" (most specific match, not \"Danmark (alle)\")", inOdense.Area)
|
|
}
|
|
elsewhere, ok := byShare["Elsewhere"]
|
|
if !ok {
|
|
t.Fatal("Elsewhere missing from GPSShares")
|
|
}
|
|
if elsewhere.Area != nil {
|
|
t.Errorf("Elsewhere.Area = %v, want nil (outside every configured area)", *elsewhere.Area)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_SessionArea covers approximating a session's
|
|
// area from its entry-point repeater (path[0]) when the sender never shares
|
|
// a literal GPS fix — must only trust a unique_prefix match, same discipline
|
|
// as /api/resolve-hops, and must never guess when a prefix is ambiguous or
|
|
// unresolvable.
|
|
func TestHandleWardrivingStats_SessionArea(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
|
|
t.Fatalf("clear observations: %v", err)
|
|
}
|
|
|
|
f := func(v float64) *float64 { return &v }
|
|
srv.cfg.Areas = map[string]AreaEntry{
|
|
"ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)},
|
|
}
|
|
|
|
// A repeater inside the configured area, resolvable only via a unique
|
|
// full-length prefix match (mirrors TestResolveHopsAPI_UniquePrefix).
|
|
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ff11223344", "OdenseRepeater", 55.4047, 10.3810, "repeater"); err != nil {
|
|
t.Fatalf("insert node: %v", err)
|
|
}
|
|
// A second, ambiguous prefix: two nodes share it, so it must never
|
|
// resolve to an area even though one of them has a position.
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ee1aaaaaaa", "AmbigA", 55.40, 10.38, "repeater")
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ee1bbbbbbb", "AmbigB", 55.41, 10.39, "repeater")
|
|
srv.store.InvalidateNodeCache()
|
|
|
|
now := time.Now().UTC()
|
|
insertTx := func(hash, sender string, ts time.Time) int64 {
|
|
res, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
"aa", hash, ts.Format(time.RFC3339), `{"sender":"`+sender+`","text":"`+sender+`: MM:x"}`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert tx %s: %v", hash, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
seaIdx := int64(0)
|
|
res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsSEA", "SeattleObs", "SEA")
|
|
if err != nil {
|
|
t.Fatalf("insert observer: %v", err)
|
|
}
|
|
seaIdx, _ = res.LastInsertId()
|
|
insertObs := func(txID int64, pathJSON string) {
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
|
|
txID, seaIdx, 1.0, -90.0, pathJSON, time.Now().Unix(),
|
|
); err != nil {
|
|
t.Fatalf("insert observation: %v", err)
|
|
}
|
|
}
|
|
|
|
txResolvable := insertTx("area1", "InOdense", now.Add(-10*time.Minute))
|
|
insertObs(txResolvable, `["ff11223344"]`)
|
|
|
|
txAmbiguous := insertTx("area2", "Ambiguous", now.Add(-10*time.Minute))
|
|
insertObs(txAmbiguous, `["ee1"]`) // shared prefix of ee1aaaaaaa and ee1bbbbbbb -- 2 candidates
|
|
|
|
txNoMatch := insertTx("area3", "NoMatch", now.Add(-10*time.Minute))
|
|
insertObs(txNoMatch, `["deadbeef99"]`)
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
|
|
bySender := map[string]WardrivingSession{}
|
|
for _, s := range resp.Sessions {
|
|
bySender[s.Sender] = s
|
|
}
|
|
inOdense, ok := bySender["InOdense"]
|
|
if !ok {
|
|
t.Fatal("InOdense session missing")
|
|
}
|
|
if inOdense.Area == nil || *inOdense.Area != "Odense by" {
|
|
t.Errorf("InOdense.Area = %v, want \"Odense by\" (unique_prefix match with a known position)", inOdense.Area)
|
|
}
|
|
ambiguous, ok := bySender["Ambiguous"]
|
|
if !ok {
|
|
t.Fatal("Ambiguous session missing")
|
|
}
|
|
if ambiguous.Area != nil {
|
|
t.Errorf("Ambiguous.Area = %v, want nil (prefix matches 2 candidates, must not guess)", *ambiguous.Area)
|
|
}
|
|
noMatch, ok := bySender["NoMatch"]
|
|
if !ok {
|
|
t.Fatal("NoMatch session missing")
|
|
}
|
|
if noMatch.Area != nil {
|
|
t.Errorf("NoMatch.Area = %v, want nil (prefix matches no known node)", *noMatch.Area)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats
|
|
// window validation.
|
|
func TestHandleWardrivingStats_InvalidWindow(t *testing.T) {
|
|
_, router := setupTestServer(t)
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=bogus", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 400 {
|
|
t.Fatalf("status=%d, want 400 for an invalid window", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingStats_EmptyChannel confirms an empty/quiet
|
|
// #wardriving channel returns well-formed empty slices, not nulls or an
|
|
// error — the frontend always expects arrays it can iterate.
|
|
func TestHandleWardrivingStats_EmptyChannel(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingStatsResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.TotalMessages != 0 {
|
|
t.Errorf("TotalMessages = %d, want 0", resp.TotalMessages)
|
|
}
|
|
if resp.TopSenders == nil || resp.EntryPoints == nil || resp.Observers == nil || resp.TimeSeries == nil || resp.SignalTimeSeries == nil || resp.Sessions == nil || resp.GPSShares == nil {
|
|
t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v SignalTimeSeries=%v Sessions=%v GPSShares=%v",
|
|
resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries, resp.SignalTimeSeries, resp.Sessions, resp.GPSShares)
|
|
}
|
|
if resp.AvgSNR != nil || resp.AvgRSSI != nil {
|
|
t.Errorf("expected nil AvgSNR/AvgRSSI for a channel with no observations, got AvgSNR=%v AvgRSSI=%v", resp.AvgSNR, resp.AvgRSSI)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingSenderMessages covers the drill-down endpoint: one
|
|
// sender's individual messages, most-recent-first, with resolved path,
|
|
// per-observer signal, and lat/lon when a specific message shared a
|
|
// position — and that another sender's messages never leak in.
|
|
func TestHandleWardrivingSenderMessages(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
|
|
t.Fatalf("clear transmissions: %v", err)
|
|
}
|
|
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
|
|
t.Fatalf("clear observations: %v", err)
|
|
}
|
|
|
|
insertTx := func(hash, sender, mmPayload string, tsOffset time.Duration) int64 {
|
|
ts := time.Now().UTC().Add(tsOffset).Format(time.RFC3339)
|
|
text := sender + ": " + mmPayload
|
|
res, err := srv.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`,
|
|
"aa", hash, ts, `{"sender":"`+sender+`","text":"`+text+`"}`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert tx %s: %v", hash, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
insertObserver := func(id, name string) int64 {
|
|
res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name) VALUES (?,?)`, id, name)
|
|
if err != nil {
|
|
t.Fatalf("insert observer %s: %v", id, err)
|
|
}
|
|
rowid, _ := res.LastInsertId()
|
|
return rowid
|
|
}
|
|
insertObs := func(txID, observerIdx int64, snr, rssi float64, pathJSON string) {
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
|
|
txID, observerIdx, snr, rssi, pathJSON, time.Now().Unix(),
|
|
); err != nil {
|
|
t.Fatalf("insert observation: %v", err)
|
|
}
|
|
}
|
|
|
|
obs1 := insertObserver("wsmObsOne", "ObsOne")
|
|
obs2 := insertObserver("wsmObsTwo", "ObsTwo")
|
|
|
|
// Alice's older message: standard token, no shared position, heard by
|
|
// ObsOne only, short path.
|
|
txOld := insertTx("aliceOld", "Alice", "MM:c3e_zJ1rUA", -2*time.Hour)
|
|
insertObs(txOld, obs1, 3.0, -85, `["AAAA"]`)
|
|
|
|
// Alice's newer message: she shared her position this time, heard by
|
|
// both observers; ObsTwo's observation carries the longer (more
|
|
// complete) path, which GetWardrivingSenderMessages should prefer.
|
|
txNew := insertTx("aliceNew", "Alice", "MM:c3e_zJ1rUA:55.59743,13.00128", -30*time.Minute)
|
|
insertObs(txNew, obs1, 5.0, -80, `["BBBB"]`)
|
|
insertObs(txNew, obs2, 7.0, -75, `["BBBB","CCCC"]`)
|
|
|
|
// Bob's message must never appear in Alice's results.
|
|
insertTx("bobMsg", "Bob", "MM:c3e_zJ1rUA", -1*time.Hour)
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving/sender-messages?sender=Alice&window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp WardrivingSenderMessagesResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
|
}
|
|
|
|
if resp.Sender != "Alice" {
|
|
t.Errorf("Sender = %q, want Alice", resp.Sender)
|
|
}
|
|
if len(resp.Messages) != 2 {
|
|
t.Fatalf("Messages = %+v, want 2 (Bob's must not leak in)", resp.Messages)
|
|
}
|
|
|
|
// Most-recent-first: txNew before txOld.
|
|
newest, oldest := resp.Messages[0], resp.Messages[1]
|
|
if newest.TransmissionID != txNew {
|
|
t.Errorf("Messages[0].TransmissionID = %d, want %d (newest first)", newest.TransmissionID, txNew)
|
|
}
|
|
if len(newest.PathPrefixes) != 2 || newest.PathPrefixes[0] != "BBBB" || newest.PathPrefixes[1] != "CCCC" {
|
|
t.Errorf("newest.PathPrefixes = %v, want [BBBB CCCC] (the longer/more complete path)", newest.PathPrefixes)
|
|
}
|
|
if len(newest.Observations) != 2 {
|
|
t.Errorf("newest.Observations = %+v, want 2 (ObsOne + ObsTwo)", newest.Observations)
|
|
}
|
|
if newest.Lat == nil || newest.Lon == nil || *newest.Lat != 55.59743 || *newest.Lon != 13.00128 {
|
|
t.Errorf("newest.Lat/Lon = %v/%v, want the shared position 55.59743/13.00128", newest.Lat, newest.Lon)
|
|
}
|
|
|
|
if oldest.TransmissionID != txOld {
|
|
t.Errorf("Messages[1].TransmissionID = %d, want %d (oldest last)", oldest.TransmissionID, txOld)
|
|
}
|
|
if len(oldest.PathPrefixes) != 1 || oldest.PathPrefixes[0] != "AAAA" {
|
|
t.Errorf("oldest.PathPrefixes = %v, want [AAAA]", oldest.PathPrefixes)
|
|
}
|
|
if oldest.Lat != nil || oldest.Lon != nil {
|
|
t.Errorf("oldest.Lat/Lon = %v/%v, want nil (standard token, no shared position)", oldest.Lat, oldest.Lon)
|
|
}
|
|
}
|
|
|
|
// TestHandleWardrivingSenderMessages_MissingSender confirms the required
|
|
// sender param is enforced.
|
|
func TestHandleWardrivingSenderMessages_MissingSender(t *testing.T) {
|
|
_, router := setupTestServer(t)
|
|
req := httptest.NewRequest("GET", "/api/analytics/wardriving/sender-messages?window=24h", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 400 {
|
|
t.Fatalf("status=%d, want 400 when sender is missing", w.Code)
|
|
}
|
|
}
|