From b3e55ae8d59fa4f4d2357e1a8bbdde4426ed1bfe Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 28 May 2026 23:55:59 +0200 Subject: [PATCH] fix(nodes): sort paths-through-node by recency, count as tiebreaker (#1145) (#1431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `/api/nodes/{pk}/paths` returned paths in non-deterministic map iteration order; with many paths the UI showed a random ordering on each page load - Now sorted by `LastSeen` descending (newest-first), with `Count` as a tiebreaker (higher first) - Nil `LastSeen` sorts last (treated as oldest) - `LastSeen` is an RFC 3339 string so lexicographic comparison is correct Closes #1145. ## Test plan - [ ] `TestHandleNodePaths_SortByRecency_1145` — 3 distinct paths (via relay1, relay2, direct), verifies newest appears first - [ ] `TestHandleNodePaths_SortCountTiebreaker_1145` — two paths with identical `LastSeen`, verifies higher-count path wins the tiebreak - [ ] All existing `TestHandleNodePaths_*` tests still pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 --- cmd/server/paths_sort_test.go | 182 ++++++++++++++++++++++++++++++++++ cmd/server/routes.go | 18 ++-- 2 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 cmd/server/paths_sort_test.go diff --git a/cmd/server/paths_sort_test.go b/cmd/server/paths_sort_test.go new file mode 100644 index 00000000..1e9efcc2 --- /dev/null +++ b/cmd/server/paths_sort_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// TestHandleNodePaths_SortByRecency_1145 is the regression test for issue #1145. +// +// Prior to the fix, paths were returned in map-iteration order (non-deterministic). +// After the fix, paths are sorted by LastSeen descending (newest first), with +// Count as a tiebreaker (higher first). +// +// Setup: target node "aa..." is reached via three distinct paths. +// +// Path A (via relay "11..."): 3 transmissions, last seen 2026-01-03 (oldest) +// Path B (via relay "22..."): 1 transmission, last seen 2026-05-01 (newest) +// Path C (direct — "aa..." only): 2 transmissions, last seen 2026-03-02 (middle) +// +// Expected sort: B (newest) → C (middle) → A (oldest) +// Also covers: when LastSeen is equal, Count descending is the tiebreaker. +func TestHandleNodePaths_SortByRecency_1145(t *testing.T) { + db := setupTestDB(t) + + targetPK := "aabbccdd11111111" + relay1PK := "1111111100000000" + relay2PK := "2222222200000000" + + epoch := func(ts string) int64 { + v, _ := time.Parse(time.RFC3339, ts) + return v.Unix() + } + + // Only the target node needs to be in the nodes table. + // Relay pubkeys appear only in resolved_path; they don't need a nodes row. + mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES (?, 'Target', 'repeater', 0, 0, '2026-05-01T00:00:00Z', '2026-01-01T00:00:00Z', 1)`, targetPK) + + // -- Path A (via relay1): 3 txs, last seen 2026-01-03 → group sig "relay1PK→targetPK" -- + for txID, ts := range map[int]string{ + 1: "2026-01-01T00:00:00Z", + 2: "2026-01-02T00:00:00Z", + 3: "2026-01-03T00:00:00Z", + } { + mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'AA', ?, ?)`, + txID, "hashA"+string(rune('0'+txID)), ts) + mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path) + VALUES (?, NULL, '["11", "aa"]', ?, ?)`, + txID, epoch(ts), `["`+relay1PK+`", "`+targetPK+`"]`) + } + + // -- Path B (via relay2): 1 tx, last seen 2026-05-01 → group sig "relay2PK→targetPK" -- + mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (4, 'BB', 'hashB1', '2026-05-01T00:00:00Z')`) + mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path) + VALUES (4, NULL, '["22", "aa"]', ?, ?)`, + epoch("2026-05-01T00:00:00Z"), `["`+relay2PK+`", "`+targetPK+`"]`) + + // -- Path C (direct — target is sole hop): 2 txs, last seen 2026-03-02 -- + for txID, ts := range map[int]string{ + 5: "2026-03-01T00:00:00Z", + 6: "2026-03-02T00:00:00Z", + } { + mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'CC', ?, ?)`, + txID, "hashC"+string(rune('0'+txID)), ts) + mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path) + VALUES (?, NULL, '["aa"]', ?, ?)`, + txID, epoch(ts), `["`+targetPK+`"]`) + } + + // Wire up server + store + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + srv.store = store + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String()) + } + var resp NodePathsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(resp.Paths) != 3 { + t.Fatalf("expected 3 distinct paths, got %d: %+v", len(resp.Paths), resp.Paths) + } + if resp.TotalTransmissions != 6 { + t.Errorf("expected TotalTransmissions=6, got %d", resp.TotalTransmissions) + } + + // Sort order: B (newest, 2026-05-01) → C (middle, 2026-03-02) → A (oldest, 2026-01-03) + wantCounts := []int{1, 2, 3} + for i, want := range wantCounts { + got := resp.Paths[i].Count + if got != want { + t.Errorf("Paths[%d].Count = %d, want %d (sort order wrong — paths must be newest-first)", i, got, want) + } + } +} + +// TestHandleNodePaths_SortCountTiebreaker_1145 verifies that when two paths +// have identical LastSeen, the one with higher Count appears first. +func TestHandleNodePaths_SortCountTiebreaker_1145(t *testing.T) { + db := setupTestDB(t) + + targetPK := "ccddeeFF11111111" + relay1PK := "aaaa111100000000" + relay2PK := "bbbb222200000000" + sameTS := "2026-04-15T12:00:00Z" + epoch := func(ts string) int64 { + v, _ := time.Parse(time.RFC3339, ts) + return v.Unix() + } + + mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES (?, 'Tgt', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, targetPK, sameTS) + + // Path X: 3 txs, all at sameTS → higher count + for txID, ts := range map[int]string{ + 10: "2026-04-15T11:00:00Z", + 11: "2026-04-15T11:30:00Z", + 12: sameTS, + } { + mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'XX', ?, ?)`, + txID, "hashX"+string(rune('0'+txID)), ts) + mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path) + VALUES (?, NULL, '["aa", "cc"]', ?, ?)`, + txID, epoch(ts), `["`+relay1PK+`", "`+targetPK+`"]`) + } + + // Path Y: 1 tx, at sameTS → lower count + mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (20, 'YY', 'hashY1', ?)`, sameTS) + mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path) + VALUES (20, NULL, '["bb", "cc"]', ?, ?)`, + epoch(sameTS), `["`+relay2PK+`", "`+targetPK+`"]`) + + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + srv.store = store + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String()) + } + var resp NodePathsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp.Paths) != 2 { + t.Fatalf("expected 2 paths, got %d", len(resp.Paths)) + } + // Path X (count=3) must sort before Path Y (count=1) when LastSeen is equal. + if resp.Paths[0].Count != 3 { + t.Errorf("Paths[0].Count = %d, want 3 (higher-count path must sort first when LastSeen equal)", resp.Paths[0].Count) + } + if resp.Paths[1].Count != 1 { + t.Errorf("Paths[1].Count = %d, want 1", resp.Paths[1].Count) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 506de169..3b811199 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1802,15 +1802,15 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) { }) } sort.Slice(paths, func(i, j int) bool { - if paths[i].Count == paths[j].Count { - li := "" - lj := "" - if paths[i].LastSeen != nil { - li = fmt.Sprintf("%v", paths[i].LastSeen) - } - if paths[j].LastSeen != nil { - lj = fmt.Sprintf("%v", paths[j].LastSeen) - } + li := "" + lj := "" + if paths[i].LastSeen != nil { + li = fmt.Sprintf("%v", paths[i].LastSeen) + } + if paths[j].LastSeen != nil { + lj = fmt.Sprintf("%v", paths[j].LastSeen) + } + if li != lj { return li > lj } return paths[i].Count > paths[j].Count