Files
meshcore-analyzer/cmd/server/packets_exclude_tx_test.go
n30nexandn30nex 1ed5e4ab3f fix(compare): exclude observer TX-only packets from overlap (#2346)
Red commit: `39295da8` ([intended CI assertion
failure](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40605770951/job/122544822813)).
Green commit: `d1bda496`.

Observer comparison counted an observer's TX-only packets as receptions,
producing false overlap. It now requests `excludeTx=true`: the selected
observer and non-TX direction must match the same observation in memory
and SQLite v2/v3. Normal packet lists and stored TX records remain
unchanged; missing directions retain legacy compatibility.

Refs #667. This completes a comparison slice, not the issue's remaining
observer counters or #671's fleet/SNR feature. Representative display
fields and existing time-filter semantics are unchanged. API/OpenAPI
docs describe the filter and multi-node bypass.

Validation:
- RED: Go tests compiled with 32 intended failures/16 control passes;
unit and browser regressions reproduced false overlap. CI reached the
new unit assertion.
- GREEN: all 48 packet cases pass, plus grouped-cache isolation and
OpenAPI tests. The added cache regression caught a malformed-radio
cache-key collision before correction.
- Full local server suite: 174s; ingestor: 185s; `npm run test:unit`:
pass. Final focused packet/grouped/OpenAPI checks: pass.
- E2E assertion added to
`tests/e2e/test-issue-1640-compare-discovery-e2e.js`; all 10 checks
pass, including correct 1/2 reception totals and 50% overlap.

Performance: still two capped bulk requests, indexed candidate lookups
retained, one direction check within the existing observation scan/SQL
predicate, no new persistent structures. No configuration/customizer
changes.

Final-head CI passed; independent reviews found no must-fix items.

---------

Co-authored-by: n30nex <6686206+n30nex@users.noreply.github.com>
2026-10-29 18:34:06 +01:00

152 lines
5.7 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"sort"
"testing"
"time"
)
// #667: reception comparisons must match observer and direction on the same
// observation, not on the transmission's globally selected display observation.
func TestPacketsExcludeTx(t *testing.T) {
for _, mode := range []string{"memory", "sql-v2", "sql-v3", "fallback"} {
t.Run(mode, func(t *testing.T) {
db := setupTestDB(t)
if mode == "sql-v2" {
db.Close()
db = setupTestDBV2(t)
}
t.Cleanup(func() { db.Close() })
now := time.Now().UTC().Truncate(time.Second)
seen := now.Add(-time.Hour)
const node = "aabbccdd11223344"
mustExec := func(query string, args ...any) {
t.Helper()
if _, err := db.conn.Exec(query, args...); err != nil {
t.Fatal(err)
}
}
mustExec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, 'Fixture node', 'repeater', ?)", node, seen.Format(time.RFC3339))
mustExec("INSERT INTO observers (id, name, iata) VALUES ('a', 'Observer A', 'AAA'), ('b', 'Observer B', 'BBB')")
for _, hash := range []string{"a-tx-b-rx", "a-rx-b-tx", "a-tx-and-rx", "legacy-empty", "legacy-null", "tx-only"} {
mustExec(`INSERT INTO transmissions (hash, raw_hex, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES (?, '1200', ?, 1, 4, '{"pubKey":"aabbccdd11223344","type":"ADVERT"}', ?)`, hash, seen.Format(time.RFC3339), node)
}
addObs := func(tx int, observer string, direction any, path string) {
t.Helper()
if db.isV3 {
mustExec(`INSERT INTO observations (transmission_id, observer_idx, direction, path_json, timestamp)
VALUES (?, (SELECT rowid FROM observers WHERE id = ?), ?, ?, ?)`, tx, observer, direction, path, seen.Unix())
} else {
mustExec(`INSERT INTO observations (transmission_id, observer_id, direction, path_json, timestamp)
VALUES (?, ?, ?, ?, ?)`, tx, observer, direction, path, seen.Unix())
}
}
addObs(1, "a", "tx", `[]`)
addObs(1, "b", "rx", `["11","22"]`) // representative RX must not count as A's reception
addObs(2, "a", "rx", `[]`)
addObs(2, "b", "tx", `["11","22"]`) // representative TX must not hide A's reception
addObs(3, "a", "tx", `["11"]`)
addObs(3, "a", "rx", `[]`)
addObs(4, "a", "", `[]`)
addObs(5, "a", nil, `[]`)
addObs(6, "a", "tx", `[]`)
srv := NewServer(db, &Config{}, NewHub())
suffix := ""
if mode == "memory" || mode == "fallback" {
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatal(err)
}
if !store.WaitIndexesReady(5 * time.Second) {
t.Fatal("packet indexes did not become ready")
}
store.oldestLoaded = ""
if mode == "fallback" {
store.oldestLoaded = seen.Format(time.RFC3339)
suffix = "&since=" + url.QueryEscape(now.Add(-2*time.Hour).Format(time.RFC3339))
}
srv.store = store
}
all := []string{"a-tx-b-rx", "a-rx-b-tx", "a-tx-and-rx", "legacy-empty", "legacy-null", "tx-only"}
rxA := []string{"a-rx-b-tx", "a-tx-and-rx", "legacy-empty", "legacy-null"}
for _, tc := range []struct {
name, query string
want []string
total int
}{
{"default preserves TX", "observer=a", all, 6},
{"false preserves TX", "observer=a&excludeTx=false", all, 6},
{"observer reception", "observer=a&excludeTx=true", rxA, 4},
{"other observer", "observer=b&excludeTx=true", []string{"a-tx-b-rx"}, 1},
{"multiple observers", "observer=a,b&excludeTx=true", all[:5], 5},
{"unscoped", "excludeTx=true", all[:5], 5},
{"hash shortcut", "hash=tx-only&excludeTx=true", []string{}, 0},
{"node shortcut", "node=" + node + "&excludeTx=true", all[:5], 5},
{"pagination after exclusion", "observer=a&excludeTx=true&limit=2&offset=1", rxA[1:3], 4},
{"grouped default warms cache", "observer=a&groupByHash=true", all, 6},
{"grouped reception isolates cache", "observer=a&groupByHash=true&excludeTx=true", rxA, 4},
{"grouped default unchanged", "observer=a&groupByHash=true", all, 6},
} {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
srv.handlePackets(w, httptest.NewRequest(http.MethodGet, "/api/packets?order=asc&"+tc.query+suffix, nil))
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var got struct {
Packets []struct{ Hash string }
Total int
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
hashes := make([]string, 0, len(got.Packets))
for _, p := range got.Packets {
hashes = append(hashes, p.Hash)
}
want := append([]string{}, tc.want...)
sort.Strings(hashes)
sort.Strings(want)
if !reflect.DeepEqual(hashes, want) || got.Total != tc.total {
t.Errorf("packets = %v (total %d), want %v (total %d)", hashes, got.Total, tc.want, tc.total)
}
})
}
})
}
}
func TestPacketsExcludeTxGroupedCacheCannotAliasRadio(t *testing.T) {
srv, router := setupTestServer(t)
t.Cleanup(func() { srv.db.Close() })
for _, tc := range []struct {
query string
total int
}{
{"", 3},
{"&radio=%7CexcludeTx", 0},
{"&excludeTx=true", 3},
} {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/packets?groupByHash=true&observer=obs1"+tc.query, nil))
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var got PacketResult
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Total != tc.total || len(got.Packets) != tc.total {
t.Errorf("query %q: got %d packets (total %d), want %d; filters must have separate cache entries", tc.query, len(got.Packets), got.Total, tc.total)
}
}
}