Files
meshcore-analyzer/cmd/server/openapi_node_routes_test.go
T
n30nexandn30nex ef17d78909 docs(api): document node resolution and battery history (#2338)
Partial follow-up to #1670; the broader documentation issue stays open.

Documents the existing node-prefix resolution and node battery-history
GET APIs: parameters, normalization and lookup rules, defaults,
ambiguity/visibility, ordered samples, nullable latest values and error
media types. The generator now has concrete success schemas for both.
Error statuses remain documented in prose under its existing convention.

Removes exactly these two entries from the known-gap list: 18 → 16, with
115 of 131 routes described. No handler, query, configuration or
dependency changes.

[RED
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40426035729/job/121931981058)
reaches the missing-documentation assertions. GREEN `521fb3ef` passes
both full native CGO suites and focused OpenAPI/real-handler contract
tests. Parent browser acceptance passed 28 checks through the rendered
Swagger UI and actual endpoints, including defaults, errors and
empty/null history.

All three independent review roles passed. [Final
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40427081025)
passes on this exact head, including all browser shards and the
two-architecture image check.

---------

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

271 lines
12 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
)
// This is a two-route #1670 backfill. Check the served spec, including the
// concrete success schemas; errors remain described in prose by this generator.
func TestOpenAPINodeResolveAndBatteryDocumentation(t *testing.T) {
spec := fetchSpec(t)
paths := asMap(t, spec["paths"], "paths")
schemas := asMap(t, asMap(t, spec["components"], "components")["schemas"], "schemas")
gaps := loadKnownGaps(t)
for _, tc := range []struct {
path, schema string
terms []string
params map[string]string
fields map[string]string
}{
{"/api/nodes/resolve", "NodeResolveResponse",
[]string{"client_node_names", "ambiguous", "blacklisted", "hidden", "400", "500", "503", "text/plain"},
map[string]string{"query:prefix": "string"},
map[string]string{"prefix": "string", "pubkey": "string", "name": "string", "ambiguous": "boolean"}},
{"/api/nodes/{pubkey}/battery", "NodeBatteryResponse",
[]string{"exact", "pubkey", "observer_metrics", "404", "500", "application/json", "error", "null"},
map[string]string{"path:pubkey": "string", "query:days": "integer"},
map[string]string{"public_key": "string", "days": "integer", "samples": "array", "latest_mv": "integer", "latest_ts": "string", "status": "string", "thresholds": "object"}},
} {
t.Run(tc.path, func(t *testing.T) {
path := asMap(t, paths[tc.path], tc.path)
op := asMap(t, path["get"], "get")
if len(path) != 1 || op["summary"] == tc.path || op["summary"] == "" {
t.Errorf("expected a documented GET, got %v", path)
}
if !reflect.DeepEqual(op["tags"], []interface{}{"nodes"}) || op["security"] != nil {
t.Errorf("expected public nodes operation, got %v", op)
}
desc, _ := op["description"].(string)
for _, term := range tc.terms {
if !strings.Contains(desc, term) {
t.Errorf("description must explain %q: %q", term, desc)
}
}
params, _ := op["parameters"].([]interface{})
if len(params) != len(tc.params) {
t.Errorf("expected %d parameters, got %v", len(tc.params), params)
}
seen := make(map[string]bool)
for _, raw := range params {
p := asMap(t, raw, "parameter")
key := fmt.Sprint(p["in"], ":", p["name"])
if seen[key] || asMap(t, p["schema"], "parameter schema")["type"] != tc.params[key] || p["required"] != (key != "query:days") {
t.Errorf("unexpected parameter: %v", p)
}
seen[key] = true
terms := map[string][]string{
"query:prefix": {"4", "64", "trim", "lowercas", "Odd"},
"query:days": {"1", "365", "7", "invalid"},
}[key]
paramDesc, _ := p["description"].(string)
for _, term := range terms {
if !strings.Contains(paramDesc, term) {
t.Errorf("%s must explain %q: %q", key, term, paramDesc)
}
}
}
response := asMap(t, asMap(t, op["responses"], "responses")["200"], "200")
media := asMap(t, asMap(t, response["content"], "content")["application/json"], "JSON")
if asMap(t, media["schema"], "response schema")["$ref"] != "#/components/schemas/"+tc.schema {
t.Errorf("expected concrete %s response schema", tc.schema)
}
schema := asMap(t, schemas[tc.schema], tc.schema)
props := asMap(t, schema["properties"], "properties")
required, _ := schema["required"].([]interface{})
if schema["type"] != "object" || len(props) != len(tc.fields) {
t.Errorf("unexpected response shape: %v", schema)
}
for field, typ := range tc.fields {
p := asMap(t, props[field], field)
optional := field == "pubkey" || field == "name"
nullable := field == "latest_mv" || field == "latest_ts"
if p["type"] != typ || slices.Contains(required, interface{}(field)) == optional || (p["nullable"] == true) != nullable {
t.Errorf("wrong type/required/nullable for %s: %v (required %v)", field, p, required)
}
}
if tc.schema == "NodeBatteryResponse" {
status := asMap(t, props["status"], "status")
if !reflect.DeepEqual(status["enum"], []interface{}{"critical", "low", "ok", "unknown"}) {
t.Errorf("wrong battery status enum: %v", status)
}
items := asMap(t, asMap(t, props["samples"], "samples")["items"], "sample")
for _, nested := range []struct {
schema map[string]interface{}
fields map[string]string
}{
{items, map[string]string{"timestamp": "string", "battery_mv": "integer"}},
{asMap(t, props["thresholds"], "thresholds"), map[string]string{"low_mv": "integer", "critical_mv": "integer"}},
} {
properties := asMap(t, nested.schema["properties"], "nested properties")
required, _ := nested.schema["required"].([]interface{})
if nested.schema["type"] != "object" || len(properties) != len(nested.fields) || len(required) != len(nested.fields) {
t.Errorf("unexpected nested shape: %v", nested.schema)
}
for field, typ := range nested.fields {
if asMap(t, properties[field], field)["type"] != typ || !slices.Contains(required, interface{}(field)) {
t.Errorf("missing required %s %s: %v", typ, field, nested.schema)
}
}
}
}
if gaps[tc.path] {
t.Error("documented route must leave the known-gap list")
}
})
}
}
// Exercise actual registered handlers separately from the documentation checks:
// public access, normalization, optional fields, defaults and error media types.
func TestNodeResolveAndBatteryDocumentedContracts(t *testing.T) {
db := setupTestDB(t)
srv := NewServer(db, &Config{APIKey: "test-node-docs-key", NodeBlacklist: []string{"deaf00"}, HiddenNamePrefixes: []string{"Hidden"}}, NewHub())
router := mux.NewRouter()
srv.RegisterRoutes(router)
pubkey := strings.Repeat("ab", 32)
for pk, name := range map[string]string{pubkey: "BatteryNode", "cafe00": "First", "cafe11": "Second", "deaf00": "Blocked", "beef00": "HiddenNode", "feed00": ""} {
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, name) VALUES (?, ?)`, pk, name); err != nil {
t.Fatal(err)
}
}
get := func(t *testing.T, path string, code int, media string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != code || w.Header().Get("Content-Type") != media {
t.Fatalf("GET %s: status=%d media=%q body=%s", path, w.Code, w.Header().Get("Content-Type"), w.Body.String())
}
return w
}
t.Run("resolve", func(t *testing.T) {
for _, tc := range []struct {
prefix string
want ResolvePrefixResp
}{
{" ABABA ", ResolvePrefixResp{Prefix: "ababa", Pubkey: pubkey, Name: "BatteryNode"}},
{"abab", ResolvePrefixResp{Prefix: "abab", Pubkey: pubkey, Name: "BatteryNode"}},
{pubkey, ResolvePrefixResp{Prefix: pubkey, Pubkey: pubkey, Name: "BatteryNode"}},
{"cafe", ResolvePrefixResp{Prefix: "cafe", Ambiguous: true}},
{"dead", ResolvePrefixResp{Prefix: "dead"}},
{"deaf", ResolvePrefixResp{Prefix: "deaf"}},
{"beef", ResolvePrefixResp{Prefix: "beef"}},
{"feed", ResolvePrefixResp{Prefix: "feed", Pubkey: "feed00"}},
} {
w := get(t, "/api/nodes/resolve?prefix="+url.QueryEscape(tc.prefix), 200, "application/json")
var got ResolvePrefixResp
var fields map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(w.Body.Bytes(), &fields); err != nil {
t.Fatal(err)
}
if got != tc.want || (fields["pubkey"] == nil) != (tc.want.Pubkey == "") || (fields["name"] == nil) != (tc.want.Name == "") {
t.Errorf("prefix %q: got %s, want %+v with empty optional fields omitted", tc.prefix, w.Body.String(), tc.want)
}
}
for _, prefix := range []string{"", "xyz", "a", "aa", "abc", strings.Repeat("a", 65)} {
w := get(t, "/api/nodes/resolve?prefix="+prefix, 400, "text/plain; charset=utf-8")
want := "prefix must be hex\n"
if prefix == "aa" || prefix == "abc" {
want = "prefix must be at least 4 hex chars\n"
}
if w.Body.String() != want {
t.Errorf("prefix %q: got %q, want %q", prefix, w.Body.String(), want)
}
}
get(t, "/api/nodes/resolve", 400, "text/plain; charset=utf-8")
})
t.Run("battery", func(t *testing.T) {
now := time.Now().UTC()
for i, age := range []time.Duration{400 * 24 * time.Hour, 8 * 24 * time.Hour, 48 * time.Hour, time.Hour} {
id := pubkey
if i%2 == 1 {
id = strings.ToUpper(id)
}
if _, err := db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`, id, now.Add(-age).Format(time.RFC3339), 3500-i*100); err != nil {
t.Fatal(err)
}
}
if _, err := db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp) VALUES (?, ?)`, pubkey, now.Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
query string
days, samples int
}{
{"", 7, 2}, {"?days=1", 1, 1}, {"?days=365", 365, 3},
{"?days=0", 7, 2}, {"?days=-1", 7, 2}, {"?days=366", 7, 2},
{"?days=invalid", 7, 2}, {"?days=2.5", 7, 2},
} {
w := get(t, "/api/nodes/"+pubkey+"/battery"+tc.query, 200, "application/json")
var body struct {
PublicKey string `json:"public_key"`
Days int `json:"days"`
Samples []NodeBatterySample `json:"samples"`
LatestMv int `json:"latest_mv"`
LatestTs string `json:"latest_ts"`
Status string `json:"status"`
Thresholds map[string]int `json:"thresholds"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.PublicKey != pubkey || body.Days != tc.days || len(body.Samples) != tc.samples || body.LatestMv != 3200 || body.LatestTs != now.Add(-time.Hour).Format(time.RFC3339) || body.Status != "low" || !reflect.DeepEqual(body.Thresholds, map[string]int{"low_mv": 3300, "critical_mv": 3000}) {
t.Errorf("%s: unexpected battery response %s", tc.query, w.Body.String())
}
if !slices.IsSortedFunc(body.Samples, func(a, b NodeBatterySample) int { return strings.Compare(a.Timestamp, b.Timestamp) }) {
t.Errorf("samples must be oldest first: %v", body.Samples)
}
}
w := get(t, "/api/nodes/feed00/battery", 200, "application/json")
var empty map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &empty); err != nil {
t.Fatal(err)
}
for key, want := range map[string]string{"samples": "[]", "latest_mv": "null", "latest_ts": "null", "status": `"unknown"`} {
if string(empty[key]) != want {
t.Errorf("empty %s = %s, want %s", key, empty[key], want)
}
}
for _, key := range []string{"not-hex", strings.ToUpper(pubkey), "abab"} {
w := get(t, "/api/nodes/"+key+"/battery", 404, "application/json")
if strings.TrimSpace(w.Body.String()) != `{"error":"node not found"}` {
t.Errorf("unknown key %q: %s", key, w.Body.String())
}
}
})
t.Run("database errors", func(t *testing.T) {
mustExecDB(t, db, `DROP TABLE observer_metrics`)
checkJSONError := func(w *httptest.ResponseRecorder) {
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || len(body) != 1 || body["error"] == "" {
t.Errorf("expected JSON error object: %s", w.Body.String())
}
}
checkJSONError(get(t, "/api/nodes/"+pubkey+"/battery", 500, "application/json"))
db.conn.Close()
checkJSONError(get(t, "/api/nodes/"+pubkey+"/battery", 500, "application/json"))
if w := get(t, "/api/nodes/resolve?prefix=abab", 500, "text/plain; charset=utf-8"); w.Body.String() != "query failed\n" {
t.Errorf("unexpected query error: %s", w.Body.String())
}
for _, unavailable := range []*DB{nil, {}} {
srv.db = unavailable
if w := get(t, "/api/nodes/resolve?prefix=abab", 503, "text/plain; charset=utf-8"); w.Body.String() != "unavailable\n" {
t.Errorf("unexpected unavailable error: %s", w.Body.String())
}
}
})
}