From 96059a5b1126aad5bb941928f3a98ff2ba33eb32 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 26 Jul 2026 18:13:23 +0200 Subject: [PATCH] fix: wire last_neighbors_report_at through the ObserverResp API DTO The Observer DB struct carried the field correctly, but handleObservers/ handleObserverDetail build a separate field-by-field ObserverResp DTO for the actual JSON response, which never picked it up -- caught live on stg (field missing from /api/observers entirely). Added handler-level tests that decode the JSON response, since the existing DB-layer test for this class of field didn't reach the DTO conversion at all. --- ...observer_neighbors_report_endpoint_test.go | 108 ++++++++++++++++++ cmd/server/routes.go | 18 +-- cmd/server/types.go | 40 ++++--- 3 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 cmd/server/observer_neighbors_report_endpoint_test.go diff --git a/cmd/server/observer_neighbors_report_endpoint_test.go b/cmd/server/observer_neighbors_report_endpoint_test.go new file mode 100644 index 00000000..c576af13 --- /dev/null +++ b/cmd/server/observer_neighbors_report_endpoint_test.go @@ -0,0 +1,108 @@ +package main + +// #1865 follow-up (cwichura, PR #1867): /api/observers and +// /api/observers/{id} must expose last_neighbors_report_at end-to-end. +// This is a handler-level test on purpose: the DB layer (db.GetObservers/ +// GetObserverByID) can carry the field correctly while the HTTP handler's +// separate ObserverResp DTO silently drops it during the field-by-field +// conversion in routes.go -- exactly the gap that shipped once already. + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +func TestHandleObservers_ExposesLastNeighborsReportAt(t *testing.T) { + srv, router := setupTestServer(t) + + ts := "2026-07-26T10:00:00Z" + if _, err := srv.db.conn.Exec(`UPDATE observers SET last_neighbors_report_at = ? WHERE id = ?`, ts, "obs1"); err != nil { + t.Fatalf("seed last_neighbors_report_at: %v", err) + } + + req := httptest.NewRequest("GET", "/api/observers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var body struct { + Observers []map[string]interface{} `json:"observers"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + + var got map[string]interface{} + for _, o := range body.Observers { + if o["id"] == "obs1" { + got = o + break + } + } + if got == nil { + t.Fatalf("obs1 not found in /api/observers response") + } + if v, ok := got["last_neighbors_report_at"]; !ok || v != ts { + t.Errorf("last_neighbors_report_at = %v (present=%v), want %q", v, ok, ts) + } +} + +func TestHandleObservers_NilLastNeighborsReportAtWhenNeverReported(t *testing.T) { + srv, router := setupTestServer(t) + _ = srv + + req := httptest.NewRequest("GET", "/api/observers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var body struct { + Observers []map[string]interface{} `json:"observers"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + if len(body.Observers) == 0 { + t.Fatal("expected at least one seeded observer") + } + // seedTestData never sets last_neighbors_report_at -- key must be + // present (not omitted) and null, so the frontend can distinguish + // "field exists, never reported" from a stale/misdeployed backend. + got := body.Observers[0] + v, ok := got["last_neighbors_report_at"] + if !ok { + t.Fatal("last_neighbors_report_at key missing from response entirely") + } + if v != nil { + t.Errorf("last_neighbors_report_at = %v, want nil for an observer that never reported", v) + } +} + +func TestHandleObserverDetail_ExposesLastNeighborsReportAt(t *testing.T) { + srv, router := setupTestServer(t) + + ts := "2026-07-26T11:00:00Z" + if _, err := srv.db.conn.Exec(`UPDATE observers SET last_neighbors_report_at = ? WHERE id = ?`, ts, "obs1"); err != nil { + t.Fatalf("seed last_neighbors_report_at: %v", err) + } + + req := httptest.NewRequest("GET", "/api/observers/obs1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var got map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + if v, ok := got["last_neighbors_report_at"]; !ok || v != ts { + t.Errorf("last_neighbors_report_at = %v (present=%v), want %q", v, ok, ts) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 6d9213a6..011416e3 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3167,10 +3167,11 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) { Model: o.Model, Firmware: o.Firmware, ClientVersion: o.ClientVersion, Radio: o.Radio, BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs, - NoiseFloor: o.NoiseFloor, - LastPacketAt: o.LastPacketAt, - PacketsLastHour: plh, - Lat: lat, Lon: lon, NodeRole: nodeRole, + NoiseFloor: o.NoiseFloor, + LastPacketAt: o.LastPacketAt, + LastNeighborsReportAt: o.LastNeighborsReportAt, + PacketsLastHour: plh, + Lat: lat, Lon: lon, NodeRole: nodeRole, CanRelay: o.CanRelay, } applyObserverNaiveClock(&resp, o, nowTime) @@ -3213,10 +3214,11 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) { Model: obs.Model, Firmware: obs.Firmware, ClientVersion: obs.ClientVersion, Radio: obs.Radio, BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs, - NoiseFloor: obs.NoiseFloor, - LastPacketAt: obs.LastPacketAt, - PacketsLastHour: plh, - CanRelay: obs.CanRelay, + NoiseFloor: obs.NoiseFloor, + LastPacketAt: obs.LastPacketAt, + LastNeighborsReportAt: obs.LastNeighborsReportAt, + PacketsLastHour: plh, + CanRelay: obs.CanRelay, } applyObserverNaiveClock(&resp, obs, time.Now().UTC()) return resp diff --git a/cmd/server/types.go b/cmd/server/types.go index e8e2287d..0e7c59f9 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -1290,24 +1290,28 @@ type ChannelMessagesResponse struct { // ─── Observers ───────────────────────────────────────────────────────────────── type ObserverResp struct { - ID string `json:"id"` - Name interface{} `json:"name"` - IATA interface{} `json:"iata"` - LastSeen interface{} `json:"last_seen"` - FirstSeen interface{} `json:"first_seen"` - PacketCount int `json:"packet_count"` - Model interface{} `json:"model"` - Firmware interface{} `json:"firmware"` - ClientVersion interface{} `json:"client_version"` - Radio interface{} `json:"radio"` - BatteryMv interface{} `json:"battery_mv"` - UptimeSecs interface{} `json:"uptime_secs"` - NoiseFloor interface{} `json:"noise_floor"` - LastPacketAt interface{} `json:"last_packet_at"` - PacketsLastHour int `json:"packetsLastHour"` - Lat interface{} `json:"lat"` - Lon interface{} `json:"lon"` - NodeRole interface{} `json:"nodeRole"` + ID string `json:"id"` + Name interface{} `json:"name"` + IATA interface{} `json:"iata"` + LastSeen interface{} `json:"last_seen"` + FirstSeen interface{} `json:"first_seen"` + PacketCount int `json:"packet_count"` + Model interface{} `json:"model"` + Firmware interface{} `json:"firmware"` + ClientVersion interface{} `json:"client_version"` + Radio interface{} `json:"radio"` + BatteryMv interface{} `json:"battery_mv"` + UptimeSecs interface{} `json:"uptime_secs"` + NoiseFloor interface{} `json:"noise_floor"` + LastPacketAt interface{} `json:"last_packet_at"` + // #1865 follow-up: timestamp of the observer's most recent /neighbors + // MQTT report. nil means never reported -- opt-in firmware feature, + // unavailable on non-PSRAM hardware, NOT a fault (cwichura on PR #1867). + LastNeighborsReportAt interface{} `json:"last_neighbors_report_at"` + PacketsLastHour int `json:"packetsLastHour"` + Lat interface{} `json:"lat"` + Lon interface{} `json:"lon"` + NodeRole interface{} `json:"nodeRole"` // Issue #1478: surface naive-clock observers to the UI. // `clock_naive` is derived from clock_last_naive_at being within the // last 24h; once decayed, all three skew fields read as zero/null so the