mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 12:18:47 +00:00
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>
271 lines
12 KiB
Go
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())
|
|
}
|
|
}
|
|
})
|
|
}
|