From 47531e5487c8695c888a5560205ef4f5ee281aa7 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:37:56 -0700 Subject: [PATCH] =?UTF-8?q?Add=20golden=20fixture=20parity=20test=20suite?= =?UTF-8?q?=20=E2=80=94=20Go=20must=20match=20Node=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Capture Node.js API response shapes from prod server as golden fixtures - Store normalized shape schema in cmd/server/testdata/golden/shapes.json covering 16 endpoints: stats, nodes, packets (raw + grouped), observers, channels, channel_messages, analytics (rf, topology, hash-sizes, distance, subpaths), bulk-health, health, perf, and node detail - Add parity_test.go with recursive shape validator: - TestParityShapes: validates Go response keys/types match Node golden - TestParityNodeDetail: validates node detail response shape - TestParityArraysNotNull: catches nil slices marshaled as null - TestParityHealthEngine: verifies Go identifies itself as engine=go - TestValidateShapeFunction: unit tests for the validator itself - Add tools/check-parity.sh for live Node vs Go comparison on VM - Shape spec handles dynamic-key objects (perObserverReach, perf.endpoints) - Nullable fields properly marked (observer lat/lon, snr/rssi, hop names) Current mismatches found (genuine Go bugs): - /api/perf: packetStore missing 8 fields, sqlite missing 2 fields - /api/nodes/{pubkey}: missing hash_sizes_seen, observations, _parsedPath, _parsedDecoded in node detail response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/server/parity_test.go | 403 ++++++ cmd/server/testdata/golden/shapes.json | 1566 ++++++++++++++++++++++++ tools/check-parity.sh | 179 +++ 3 files changed, 2148 insertions(+) create mode 100644 cmd/server/parity_test.go create mode 100644 cmd/server/testdata/golden/shapes.json create mode 100644 tools/check-parity.sh diff --git a/cmd/server/parity_test.go b/cmd/server/parity_test.go new file mode 100644 index 0000000..270eb31 --- /dev/null +++ b/cmd/server/parity_test.go @@ -0,0 +1,403 @@ +package main + +// parity_test.go — Golden fixture shape tests. +// Validates that Go API responses match the shape of Node.js API responses. +// Shapes were captured from the production Node.js server and stored in +// testdata/golden/shapes.json. + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// shapeSpec describes the expected JSON structure from the Node.js server. +type shapeSpec struct { + Type string `json:"type"` + Keys map[string]shapeSpec `json:"keys,omitempty"` + ElementShape *shapeSpec `json:"elementShape,omitempty"` + DynamicKeys bool `json:"dynamicKeys,omitempty"` + ValueShape *shapeSpec `json:"valueShape,omitempty"` + RequiredKeys map[string]shapeSpec `json:"requiredKeys,omitempty"` +} + +// loadShapes reads testdata/golden/shapes.json relative to this source file. +func loadShapes(t *testing.T) map[string]shapeSpec { + t.Helper() + _, thisFile, _, _ := runtime.Caller(0) + dir := filepath.Dir(thisFile) + data, err := os.ReadFile(filepath.Join(dir, "testdata", "golden", "shapes.json")) + if err != nil { + t.Fatalf("cannot load shapes.json: %v", err) + } + var shapes map[string]shapeSpec + if err := json.Unmarshal(data, &shapes); err != nil { + t.Fatalf("cannot parse shapes.json: %v", err) + } + return shapes +} + +// validateShape recursively checks that `actual` matches the expected `spec`. +// `path` tracks the JSON path for error messages. +// Returns a list of mismatch descriptions. +func validateShape(actual interface{}, spec shapeSpec, path string) []string { + var errs []string + + switch spec.Type { + case "null", "nullable": + // nullable means: value can be null OR matching type. Accept anything. + return nil + case "nullable_number": + // Can be null or number + if actual != nil { + if _, ok := actual.(float64); !ok { + errs = append(errs, fmt.Sprintf("%s: expected number or null, got %T", path, actual)) + } + } + return errs + case "string": + if actual == nil { + errs = append(errs, fmt.Sprintf("%s: expected string, got null", path)) + } else if _, ok := actual.(string); !ok { + errs = append(errs, fmt.Sprintf("%s: expected string, got %T", path, actual)) + } + case "number": + if actual == nil { + errs = append(errs, fmt.Sprintf("%s: expected number, got null", path)) + } else if _, ok := actual.(float64); !ok { + errs = append(errs, fmt.Sprintf("%s: expected number, got %T (%v)", path, actual, actual)) + } + case "boolean": + if actual == nil { + errs = append(errs, fmt.Sprintf("%s: expected boolean, got null", path)) + } else if _, ok := actual.(bool); !ok { + errs = append(errs, fmt.Sprintf("%s: expected boolean, got %T", path, actual)) + } + case "array": + if actual == nil { + errs = append(errs, fmt.Sprintf("%s: expected array, got null (arrays must be [] not null)", path)) + return errs + } + arr, ok := actual.([]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("%s: expected array, got %T", path, actual)) + return errs + } + if spec.ElementShape != nil && len(arr) > 0 { + errs = append(errs, validateShape(arr[0], *spec.ElementShape, path+"[0]")...) + } + case "object": + if actual == nil { + errs = append(errs, fmt.Sprintf("%s: expected object, got null", path)) + return errs + } + obj, ok := actual.(map[string]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("%s: expected object, got %T", path, actual)) + return errs + } + + if spec.DynamicKeys { + // Object with dynamic keys — validate value shapes + if spec.ValueShape != nil && len(obj) > 0 { + for k, v := range obj { + errs = append(errs, validateShape(v, *spec.ValueShape, path+"."+k)...) + break // check just one sample + } + } + if spec.RequiredKeys != nil { + for rk, rs := range spec.RequiredKeys { + v, exists := obj[rk] + if !exists { + errs = append(errs, fmt.Sprintf("%s: missing required key %q in dynamic-key object", path, rk)) + } else { + errs = append(errs, validateShape(v, rs, path+"."+rk)...) + } + } + } + } else if spec.Keys != nil { + // Object with known keys — check each expected key exists and has correct type + for key, keySpec := range spec.Keys { + val, exists := obj[key] + if !exists { + errs = append(errs, fmt.Sprintf("%s: missing field %q (expected %s)", path, key, keySpec.Type)) + } else { + errs = append(errs, validateShape(val, keySpec, path+"."+key)...) + } + } + } + } + + return errs +} + +// parityEndpoint defines one endpoint to test for parity. +type parityEndpoint struct { + name string // key in shapes.json + path string // HTTP path to request +} + +func TestParityShapes(t *testing.T) { + shapes := loadShapes(t) + _, router := setupTestServer(t) + + endpoints := []parityEndpoint{ + {"stats", "/api/stats"}, + {"nodes", "/api/nodes?limit=5"}, + {"packets", "/api/packets?limit=5"}, + {"packets_grouped", "/api/packets?limit=5&groupByHash=true"}, + {"observers", "/api/observers"}, + {"channels", "/api/channels"}, + {"channel_messages", "/api/channels/0000000000000000/messages?limit=5"}, + {"analytics_rf", "/api/analytics/rf?days=7"}, + {"analytics_topology", "/api/analytics/topology?days=7"}, + {"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"}, + {"analytics_distance", "/api/analytics/distance?days=7"}, + {"analytics_subpaths", "/api/analytics/subpaths?days=7"}, + {"bulk_health", "/api/nodes/bulk-health"}, + {"health", "/api/health"}, + {"perf", "/api/perf"}, + } + + for _, ep := range endpoints { + t.Run("Parity_"+ep.name, func(t *testing.T) { + spec, ok := shapes[ep.name] + if !ok { + t.Fatalf("no shape spec found for %q in shapes.json", ep.name) + } + + req := httptest.NewRequest("GET", ep.path, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("GET %s returned %d, expected 200. Body: %s", + ep.path, w.Code, w.Body.String()) + } + + var body interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("GET %s returned invalid JSON: %v\nBody: %s", + ep.path, err, w.Body.String()) + } + + mismatches := validateShape(body, spec, ep.path) + if len(mismatches) > 0 { + t.Errorf("Go %s has %d shape mismatches vs Node.js golden:\n %s", + ep.path, len(mismatches), strings.Join(mismatches, "\n ")) + } + }) + } +} + +// TestParityNodeDetail tests node detail endpoint shape. +// Uses a known test node public key from seeded data. +func TestParityNodeDetail(t *testing.T) { + shapes := loadShapes(t) + _, router := setupTestServer(t) + + spec, ok := shapes["node_detail"] + if !ok { + t.Fatal("no shape spec for node_detail in shapes.json") + } + + req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("node detail returned %d: %s", w.Code, w.Body.String()) + } + + var body interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + mismatches := validateShape(body, spec, "/api/nodes/{pubkey}") + if len(mismatches) > 0 { + t.Errorf("Go node detail has %d shape mismatches vs Node.js golden:\n %s", + len(mismatches), strings.Join(mismatches, "\n ")) + } +} + +// TestParityArraysNotNull verifies that array-typed fields in Go responses are +// [] (empty array) rather than null. This is a common Go/JSON pitfall where +// nil slices marshal as null instead of []. +// Uses shapes.json to know which fields SHOULD be arrays. +func TestParityArraysNotNull(t *testing.T) { + shapes := loadShapes(t) + _, router := setupTestServer(t) + + endpoints := []struct { + name string + path string + }{ + {"stats", "/api/stats"}, + {"nodes", "/api/nodes?limit=5"}, + {"packets", "/api/packets?limit=5"}, + {"packets_grouped", "/api/packets?limit=5&groupByHash=true"}, + {"observers", "/api/observers"}, + {"channels", "/api/channels"}, + {"bulk_health", "/api/nodes/bulk-health"}, + {"analytics_rf", "/api/analytics/rf?days=7"}, + {"analytics_topology", "/api/analytics/topology?days=7"}, + {"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"}, + {"analytics_distance", "/api/analytics/distance?days=7"}, + {"analytics_subpaths", "/api/analytics/subpaths?days=7"}, + } + + for _, ep := range endpoints { + t.Run("NullArrayCheck_"+ep.name, func(t *testing.T) { + spec, ok := shapes[ep.name] + if !ok { + t.Skipf("no shape spec for %s", ep.name) + } + + req := httptest.NewRequest("GET", ep.path, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Skipf("GET %s returned %d, skipping null-array check", ep.path, w.Code) + } + + var body interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + nullArrays := findNullArrays(body, spec, ep.path) + if len(nullArrays) > 0 { + t.Errorf("Go %s has null where [] expected:\n %s\n"+ + "Go nil slices marshal as null — initialize with make() or literal", + ep.path, strings.Join(nullArrays, "\n ")) + } + }) + } +} + +// findNullArrays walks JSON data alongside a shape spec and returns paths +// where the spec says the field should be an array but Go returned null. +func findNullArrays(actual interface{}, spec shapeSpec, path string) []string { + var nulls []string + + switch spec.Type { + case "array": + if actual == nil { + nulls = append(nulls, fmt.Sprintf("%s: null (should be [])", path)) + } else if arr, ok := actual.([]interface{}); ok && spec.ElementShape != nil { + for i, elem := range arr { + nulls = append(nulls, findNullArrays(elem, *spec.ElementShape, fmt.Sprintf("%s[%d]", path, i))...) + } + } + case "object": + obj, ok := actual.(map[string]interface{}) + if !ok || obj == nil { + return nulls + } + if spec.Keys != nil { + for key, keySpec := range spec.Keys { + if val, exists := obj[key]; exists { + nulls = append(nulls, findNullArrays(val, keySpec, path+"."+key)...) + } else if keySpec.Type == "array" { + // Key missing entirely — also a null-array problem + nulls = append(nulls, fmt.Sprintf("%s.%s: missing (should be [])", path, key)) + } + } + } + if spec.DynamicKeys && spec.ValueShape != nil { + for k, v := range obj { + nulls = append(nulls, findNullArrays(v, *spec.ValueShape, path+"."+k)...) + break // sample one + } + } + } + + return nulls +} + +// TestParityHealthEngine verifies Go health endpoint declares engine=go +// while Node declares engine=node (or omits it). The Go server must always +// identify itself. +func TestParityHealthEngine(t *testing.T) { + _, router := setupTestServer(t) + + req := httptest.NewRequest("GET", "/api/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + + engine, ok := body["engine"] + if !ok { + t.Error("health response missing 'engine' field (Go server must include engine=go)") + } else if engine != "go" { + t.Errorf("health engine=%v, expected 'go'", engine) + } +} + +// TestValidateShapeFunction directly tests the shape validator itself. +func TestValidateShapeFunction(t *testing.T) { + t.Run("string match", func(t *testing.T) { + errs := validateShape("hello", shapeSpec{Type: "string"}, "$.x") + if len(errs) != 0 { + t.Errorf("unexpected errors: %v", errs) + } + }) + + t.Run("string mismatch", func(t *testing.T) { + errs := validateShape(42.0, shapeSpec{Type: "string"}, "$.x") + if len(errs) != 1 { + t.Errorf("expected 1 error, got %d: %v", len(errs), errs) + } + }) + + t.Run("null array rejected", func(t *testing.T) { + errs := validateShape(nil, shapeSpec{Type: "array"}, "$.arr") + if len(errs) != 1 || !strings.Contains(errs[0], "null") { + t.Errorf("expected null-array error, got: %v", errs) + } + }) + + t.Run("empty array OK", func(t *testing.T) { + errs := validateShape([]interface{}{}, shapeSpec{Type: "array"}, "$.arr") + if len(errs) != 0 { + t.Errorf("unexpected errors for empty array: %v", errs) + } + }) + + t.Run("missing object key", func(t *testing.T) { + spec := shapeSpec{Type: "object", Keys: map[string]shapeSpec{ + "name": {Type: "string"}, + "age": {Type: "number"}, + }} + obj := map[string]interface{}{"name": "test"} + errs := validateShape(obj, spec, "$.user") + if len(errs) != 1 || !strings.Contains(errs[0], "age") { + t.Errorf("expected missing age error, got: %v", errs) + } + }) + + t.Run("nullable allows null", func(t *testing.T) { + errs := validateShape(nil, shapeSpec{Type: "nullable"}, "$.x") + if len(errs) != 0 { + t.Errorf("nullable should accept null: %v", errs) + } + }) + + t.Run("dynamic keys validates value shape", func(t *testing.T) { + spec := shapeSpec{ + Type: "object", + DynamicKeys: true, + ValueShape: &shapeSpec{Type: "number"}, + } + obj := map[string]interface{}{"a": 1.0, "b": 2.0} + errs := validateShape(obj, spec, "$.dyn") + if len(errs) != 0 { + t.Errorf("unexpected errors: %v", errs) + } + }) +} diff --git a/cmd/server/testdata/golden/shapes.json b/cmd/server/testdata/golden/shapes.json new file mode 100644 index 0000000..d5267dc --- /dev/null +++ b/cmd/server/testdata/golden/shapes.json @@ -0,0 +1,1566 @@ +{ + "analytics_distance": { + "type": "object", + "keys": { + "summary": { + "type": "object", + "keys": { + "totalHops": { + "type": "number" + }, + "totalPaths": { + "type": "number" + }, + "avgDist": { + "type": "number" + }, + "maxDist": { + "type": "number" + } + } + }, + "topHops": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "fromName": { + "type": "string" + }, + "fromPk": { + "type": "string" + }, + "toName": { + "type": "string" + }, + "toPk": { + "type": "string" + }, + "dist": { + "type": "number" + }, + "type": { + "type": "string" + }, + "snr": { + "type": "number" + }, + "hash": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + } + }, + "topPaths": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hash": { + "type": "string" + }, + "totalDist": { + "type": "number" + }, + "hopCount": { + "type": "number" + }, + "timestamp": { + "type": "string" + }, + "hops": { + "type": "array", + "elementShape": { + "type": "object" + } + } + } + } + }, + "catStats": { + "type": "object", + "dynamicKeys": true, + "valueShape": { + "type": "object", + "keys": { + "count": { + "type": "number" + }, + "avg": { + "type": "number" + }, + "median": { + "type": "number" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + } + } + }, + "distHistogram": { + "type": "object", + "keys": { + "bins": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "x": { + "type": "number" + }, + "w": { + "type": "number" + }, + "count": { + "type": "number" + } + } + } + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + } + }, + "distOverTime": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hour": { + "type": "string" + }, + "avg": { + "type": "number" + }, + "count": { + "type": "number" + } + } + } + } + } + }, + "analytics_hash_sizes": { + "type": "object", + "keys": { + "total": { + "type": "number" + }, + "distribution": { + "type": "object", + "dynamicKeys": true, + "valueShape": { + "type": "number" + } + }, + "hourly": { + "type": "array", + "elementShape": { + "type": "object", + "dynamicKeys": true, + "valueShape": { + "type": "number" + }, + "requiredKeys": { + "hour": { + "type": "string" + } + } + } + }, + "topHops": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hex": { + "type": "string" + }, + "size": { + "type": "number" + }, + "count": { + "type": "number" + }, + "name": { + "type": "nullable" + }, + "pubkey": { + "type": "nullable" + } + } + } + }, + "multiByteNodes": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "name": { + "type": "string" + }, + "hashSize": { + "type": "number" + }, + "packets": { + "type": "number" + }, + "lastSeen": { + "type": "string" + }, + "pubkey": { + "type": "string" + } + } + } + } + } + }, + "analytics_rf": { + "type": "object", + "keys": { + "totalPackets": { + "type": "number" + }, + "totalAllPackets": { + "type": "number" + }, + "totalTransmissions": { + "type": "number" + }, + "snr": { + "type": "object", + "keys": { + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "avg": { + "type": "number" + }, + "median": { + "type": "number" + }, + "stddev": { + "type": "number" + } + } + }, + "rssi": { + "type": "object", + "keys": { + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "avg": { + "type": "number" + }, + "median": { + "type": "number" + }, + "stddev": { + "type": "number" + } + } + }, + "snrValues": { + "type": "object", + "keys": { + "bins": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "x": { + "type": "number" + }, + "w": { + "type": "number" + }, + "count": { + "type": "number" + } + } + } + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + } + }, + "rssiValues": { + "type": "object", + "keys": { + "bins": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "x": { + "type": "number" + }, + "w": { + "type": "number" + }, + "count": { + "type": "number" + } + } + } + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + } + }, + "packetSizes": { + "type": "object", + "keys": { + "bins": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "x": { + "type": "number" + }, + "w": { + "type": "number" + }, + "count": { + "type": "number" + } + } + } + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + } + }, + "minPacketSize": { + "type": "number" + }, + "maxPacketSize": { + "type": "number" + }, + "avgPacketSize": { + "type": "number" + }, + "packetsPerHour": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hour": { + "type": "string" + }, + "count": { + "type": "number" + } + } + } + }, + "payloadTypes": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "type": { + "type": "nullable_number" + }, + "name": { + "type": "string" + }, + "count": { + "type": "number" + } + } + } + }, + "snrByType": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "name": { + "type": "string" + }, + "count": { + "type": "number" + }, + "avg": { + "type": "number" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + } + } + }, + "signalOverTime": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hour": { + "type": "string" + }, + "count": { + "type": "number" + }, + "avgSnr": { + "type": "number" + } + } + } + }, + "scatterData": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "snr": { + "type": "number" + }, + "rssi": { + "type": "number" + } + } + } + }, + "timeSpanHours": { + "type": "number" + } + } + }, + "analytics_subpaths": { + "type": "object", + "keys": { + "subpaths": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "path": { + "type": "string" + }, + "rawHops": { + "type": "array", + "elementShape": { + "type": "string" + } + }, + "count": { + "type": "number" + }, + "hops": { + "type": "number" + }, + "pct": { + "type": "number" + } + } + } + }, + "totalPaths": { + "type": "number" + } + } + }, + "analytics_topology": { + "type": "object", + "keys": { + "uniqueNodes": { + "type": "number" + }, + "avgHops": { + "type": "number" + }, + "medianHops": { + "type": "number" + }, + "maxHops": { + "type": "number" + }, + "hopDistribution": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hops": { + "type": "number" + }, + "count": { + "type": "number" + } + } + } + }, + "topRepeaters": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hop": { + "type": "string" + }, + "count": { + "type": "number" + }, + "name": { + "type": "nullable" + }, + "pubkey": { + "type": "nullable" + } + } + } + }, + "topPairs": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hopA": { + "type": "string" + }, + "hopB": { + "type": "string" + }, + "count": { + "type": "number" + }, + "nameA": { + "type": "nullable" + }, + "nameB": { + "type": "nullable" + }, + "pubkeyA": { + "type": "nullable" + }, + "pubkeyB": { + "type": "nullable" + } + } + } + }, + "hopsVsSnr": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hops": { + "type": "number" + }, + "count": { + "type": "number" + }, + "avgSnr": { + "type": "number" + } + } + } + }, + "observers": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, + "perObserverReach": { + "type": "object", + "dynamicKeys": true, + "valueShape": { + "type": "object", + "keys": { + "observer_name": { + "type": "string" + }, + "rings": { + "type": "array", + "elementShape": { + "type": "object" + } + } + } + } + }, + "multiObsNodes": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hop": { + "type": "string" + }, + "name": { + "type": "nullable" + }, + "pubkey": { + "type": "nullable" + }, + "observers": { + "type": "array", + "elementShape": { + "type": "object" + } + } + } + } + }, + "bestPathList": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hop": { + "type": "string" + }, + "name": { + "type": "nullable" + }, + "pubkey": { + "type": "nullable" + }, + "minDist": { + "type": "number" + }, + "observer_id": { + "type": "string" + }, + "observer_name": { + "type": "string" + } + } + } + } + } + }, + "bulk_health": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "public_key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "lat": { + "type": "number" + }, + "lon": { + "type": "number" + }, + "stats": { + "type": "object", + "keys": { + "totalTransmissions": { + "type": "number" + }, + "totalObservations": { + "type": "number" + }, + "totalPackets": { + "type": "number" + }, + "packetsToday": { + "type": "number" + }, + "avgSnr": { + "type": "nullable" + }, + "lastHeard": { + "type": "nullable" + } + } + }, + "observers": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "observer_id": { + "type": "string" + }, + "observer_name": { + "type": "string" + }, + "avgSnr": { + "type": "nullable" + }, + "avgRssi": { + "type": "nullable" + }, + "packetCount": { + "type": "number" + } + } + } + } + } + } + }, + "channel_messages": { + "type": "object", + "keys": { + "messages": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "sender": { + "type": "string" + }, + "text": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "sender_timestamp": { + "type": "number" + }, + "packetId": { + "type": "number" + }, + "packetHash": { + "type": "string" + }, + "repeats": { + "type": "number" + }, + "observers": { + "type": "array", + "elementShape": { + "type": "string" + } + }, + "hops": { + "type": "number" + }, + "snr": { + "type": "nullable" + } + } + } + }, + "total": { + "type": "number" + } + } + }, + "channels": { + "type": "object", + "keys": { + "channels": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hash": { + "type": "string" + }, + "name": { + "type": "string" + }, + "lastMessage": { + "type": "string" + }, + "lastSender": { + "type": "string" + }, + "messageCount": { + "type": "number" + }, + "lastActivity": { + "type": "string" + } + } + } + } + } + }, + "health": { + "type": "object", + "keys": { + "status": { + "type": "string" + }, + "uptime": { + "type": "number" + }, + "uptimeHuman": { + "type": "string" + }, + "memory": { + "type": "object", + "keys": { + "rss": { + "type": "number" + }, + "heapUsed": { + "type": "number" + }, + "heapTotal": { + "type": "number" + }, + "external": { + "type": "number" + } + } + }, + "eventLoop": { + "type": "object", + "keys": { + "currentLagMs": { + "type": "number" + }, + "maxLagMs": { + "type": "number" + }, + "p50Ms": { + "type": "number" + }, + "p95Ms": { + "type": "number" + }, + "p99Ms": { + "type": "number" + } + } + }, + "cache": { + "type": "object", + "keys": { + "entries": { + "type": "number" + }, + "hits": { + "type": "number" + }, + "misses": { + "type": "number" + }, + "staleHits": { + "type": "number" + }, + "recomputes": { + "type": "number" + }, + "hitRate": { + "type": "number" + } + } + }, + "websocket": { + "type": "object", + "keys": { + "clients": { + "type": "number" + } + } + }, + "packetStore": { + "type": "object", + "keys": { + "packets": { + "type": "number" + }, + "estimatedMB": { + "type": "number" + } + } + }, + "perf": { + "type": "object", + "keys": { + "totalRequests": { + "type": "number" + }, + "avgMs": { + "type": "number" + }, + "slowQueries": { + "type": "number" + }, + "recentSlow": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "path": { + "type": "string" + }, + "ms": { + "type": "number" + }, + "time": { + "type": "string" + }, + "status": { + "type": "number" + } + } + } + } + } + } + } + }, + "node_detail": { + "type": "object", + "keys": { + "node": { + "type": "object", + "keys": { + "public_key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "lat": { + "type": "number" + }, + "lon": { + "type": "number" + }, + "last_seen": { + "type": "string" + }, + "first_seen": { + "type": "string" + }, + "advert_count": { + "type": "number" + }, + "hash_size": { + "type": "number" + }, + "hash_size_inconsistent": { + "type": "boolean" + }, + "hash_sizes_seen": { + "type": "array", + "elementShape": { + "type": "number" + } + } + } + }, + "recentAdverts": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "id": { + "type": "number" + }, + "raw_hex": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "first_seen": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "route_type": { + "type": "number" + }, + "payload_type": { + "type": "number" + }, + "decoded_json": { + "type": "string" + }, + "observations": { + "type": "array", + "elementShape": { + "type": "object" + } + }, + "observation_count": { + "type": "number" + }, + "observer_id": { + "type": "string" + }, + "observer_name": { + "type": "string" + }, + "snr": { + "type": "nullable" + }, + "rssi": { + "type": "nullable" + }, + "path_json": { + "type": "string" + }, + "_parsedPath": { + "type": "array", + "elementShape": { + "type": "string" + } + }, + "_parsedDecoded": { + "type": "object", + "keys": { + "type": { + "type": "string" + }, + "pubKey": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "timestampISO": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "flags": { + "type": "object" + }, + "lat": { + "type": "number" + }, + "lon": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + }, + "nodes": { + "type": "object", + "keys": { + "nodes": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "public_key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "lat": { + "type": "number" + }, + "lon": { + "type": "number" + }, + "last_seen": { + "type": "string" + }, + "first_seen": { + "type": "string" + }, + "advert_count": { + "type": "number" + }, + "hash_size": { + "type": "number" + }, + "hash_size_inconsistent": { + "type": "boolean" + }, + "last_heard": { + "type": "string" + } + } + } + }, + "total": { + "type": "number" + }, + "counts": { + "type": "object", + "keys": { + "repeaters": { + "type": "number" + }, + "rooms": { + "type": "number" + }, + "companions": { + "type": "number" + }, + "sensors": { + "type": "number" + } + } + } + } + }, + "observers": { + "type": "object", + "keys": { + "observers": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "iata": { + "type": "string" + }, + "last_seen": { + "type": "string" + }, + "first_seen": { + "type": "string" + }, + "packet_count": { + "type": "number" + }, + "model": { + "type": "nullable" + }, + "firmware": { + "type": "nullable" + }, + "client_version": { + "type": "nullable" + }, + "radio": { + "type": "nullable" + }, + "battery_mv": { + "type": "nullable" + }, + "uptime_secs": { + "type": "nullable" + }, + "noise_floor": { + "type": "nullable" + }, + "packetsLastHour": { + "type": "number" + }, + "lat": { + "type": "nullable" + }, + "lon": { + "type": "nullable" + }, + "nodeRole": { + "type": "nullable" + } + } + } + }, + "server_time": { + "type": "string" + } + } + }, + "packets": { + "type": "object", + "keys": { + "packets": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "id": { + "type": "number" + }, + "raw_hex": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "first_seen": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "route_type": { + "type": "number" + }, + "payload_type": { + "type": "number" + }, + "decoded_json": { + "type": "string" + }, + "observation_count": { + "type": "number" + }, + "observer_id": { + "type": "string" + }, + "observer_name": { + "type": "string" + }, + "snr": { + "type": "nullable" + }, + "rssi": { + "type": "nullable" + }, + "path_json": { + "type": "string" + } + } + } + }, + "total": { + "type": "number" + } + } + }, + "packets_grouped": { + "type": "object", + "keys": { + "packets": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "hash": { + "type": "string" + }, + "first_seen": { + "type": "string" + }, + "count": { + "type": "number" + }, + "observer_count": { + "type": "number" + }, + "latest": { + "type": "string" + }, + "observer_id": { + "type": "string" + }, + "observer_name": { + "type": "string" + }, + "path_json": { + "type": "string" + }, + "payload_type": { + "type": "number" + }, + "route_type": { + "type": "number" + }, + "raw_hex": { + "type": "string" + }, + "decoded_json": { + "type": "string" + }, + "observation_count": { + "type": "number" + }, + "snr": { + "type": "nullable" + }, + "rssi": { + "type": "nullable" + } + } + } + }, + "total": { + "type": "number" + } + } + }, + "perf": { + "type": "object", + "keys": { + "uptime": { + "type": "number" + }, + "totalRequests": { + "type": "number" + }, + "avgMs": { + "type": "number" + }, + "endpoints": { + "type": "object", + "dynamicKeys": true, + "valueShape": { + "type": "object", + "keys": { + "count": { + "type": "number" + }, + "avgMs": { + "type": "number" + }, + "p50Ms": { + "type": "number" + }, + "p95Ms": { + "type": "number" + }, + "maxMs": { + "type": "number" + } + } + } + }, + "slowQueries": { + "type": "array", + "elementShape": { + "type": "object", + "keys": { + "path": { + "type": "string" + }, + "ms": { + "type": "number" + }, + "time": { + "type": "string" + }, + "status": { + "type": "number" + } + } + } + }, + "cache": { + "type": "object", + "keys": { + "size": { + "type": "number" + }, + "hits": { + "type": "number" + }, + "misses": { + "type": "number" + }, + "staleHits": { + "type": "number" + }, + "recomputes": { + "type": "number" + }, + "hitRate": { + "type": "number" + } + } + }, + "packetStore": { + "type": "object", + "keys": { + "totalLoaded": { + "type": "number" + }, + "totalObservations": { + "type": "number" + }, + "evicted": { + "type": "number" + }, + "inserts": { + "type": "number" + }, + "queries": { + "type": "number" + }, + "inMemory": { + "type": "number" + }, + "sqliteOnly": { + "type": "boolean" + }, + "maxPackets": { + "type": "number" + }, + "estimatedMB": { + "type": "number" + }, + "maxMB": { + "type": "number" + }, + "indexes": { + "type": "object", + "keys": { + "byHash": { + "type": "number" + }, + "byObserver": { + "type": "number" + }, + "byNode": { + "type": "number" + }, + "advertByObserver": { + "type": "number" + } + } + } + } + }, + "sqlite": { + "type": "object", + "keys": { + "dbSizeMB": { + "type": "number" + }, + "walSizeMB": { + "type": "number" + }, + "freelistMB": { + "type": "number" + }, + "walPages": { + "type": "object", + "keys": { + "total": { + "type": "number" + }, + "checkpointed": { + "type": "number" + }, + "busy": { + "type": "number" + } + } + }, + "rows": { + "type": "object", + "keys": { + "transmissions": { + "type": "number" + }, + "observations": { + "type": "number" + }, + "nodes": { + "type": "number" + }, + "observers": { + "type": "number" + } + } + } + } + } + } + }, + "stats": { + "type": "object", + "keys": { + "totalPackets": { + "type": "number" + }, + "totalTransmissions": { + "type": "number" + }, + "totalObservations": { + "type": "number" + }, + "totalNodes": { + "type": "number" + }, + "totalNodesAllTime": { + "type": "number" + }, + "totalObservers": { + "type": "number" + }, + "packetsLastHour": { + "type": "number" + }, + "counts": { + "type": "object", + "keys": { + "repeaters": { + "type": "number" + }, + "rooms": { + "type": "number" + }, + "companions": { + "type": "number" + }, + "sensors": { + "type": "number" + } + } + } + } + } +} \ No newline at end of file diff --git a/tools/check-parity.sh b/tools/check-parity.sh new file mode 100644 index 0000000..df343f4 --- /dev/null +++ b/tools/check-parity.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# tools/check-parity.sh — Compare Node.js and Go API response shapes +# +# Usage: +# bash tools/check-parity.sh # run on VM (default ports) +# bash tools/check-parity.sh NODE_PORT GO_PORT # custom ports +# ssh deploy@ 'bash ~/meshcore-analyzer/tools/check-parity.sh' +# +# Compares response SHAPES (keys + types), not values. +# Requires: curl, python3 + +set -euo pipefail + +NODE_PORT="${1:-3000}" +GO_PORT="${2:-3001}" +NODE_BASE="http://localhost:${NODE_PORT}" +GO_BASE="http://localhost:${GO_PORT}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +PASS=0 +FAIL=0 +SKIP=0 + +ENDPOINTS=( + "/api/stats" + "/api/nodes?limit=5" + "/api/packets?limit=5" + "/api/packets?limit=5&groupByHash=true" + "/api/observers" + "/api/channels" + "/api/channels/public/messages?limit=5" + "/api/analytics/rf?days=7" + "/api/analytics/topology?days=7" + "/api/analytics/hash-sizes?days=7" + "/api/analytics/distance?days=7" + "/api/analytics/subpaths?days=7" + "/api/nodes/bulk-health" + "/api/health" + "/api/perf" +) + +# Python helper to extract shape and compare +SHAPE_SCRIPT=' +import json, sys + +def extract_shape(val, depth=0, max_depth=4): + if val is None: + return "null" + if isinstance(val, bool): + return "boolean" + if isinstance(val, (int, float)): + return "number" + if isinstance(val, str): + return "string" + if isinstance(val, list): + if len(val) > 0 and depth < max_depth: + return {"array": extract_shape(val[0], depth + 1)} + return "array" + if isinstance(val, dict): + if depth >= max_depth: + return "object" + return {k: extract_shape(v, depth + 1) for k, v in sorted(val.items())} + return "unknown" + +def compare_shapes(node_shape, go_shape, path="$"): + """Compare two shapes recursively. Returns list of mismatch strings.""" + mismatches = [] + + if isinstance(node_shape, str) and isinstance(go_shape, str): + # Both are scalar types + if node_shape == "null": + return [] # null in node is OK (nullable field) + if go_shape == "null" and node_shape != "null": + mismatches.append(f"{path}: Node={node_shape}, Go=null") + elif node_shape != go_shape: + mismatches.append(f"{path}: Node={node_shape}, Go={go_shape}") + return mismatches + + if isinstance(node_shape, str) and isinstance(go_shape, dict): + mismatches.append(f"{path}: Node={node_shape}, Go=object/array") + return mismatches + + if isinstance(node_shape, dict) and isinstance(go_shape, str): + if go_shape == "null": + mismatches.append(f"{path}: Node=object/array, Go=null (nil slice/map?)") + else: + mismatches.append(f"{path}: Node=object/array, Go={go_shape}") + return mismatches + + if isinstance(node_shape, dict) and isinstance(go_shape, dict): + # Check for array shape + if "array" in node_shape and "array" not in go_shape: + mismatches.append(f"{path}: Node=array, Go=object") + return mismatches + if "array" in node_shape and "array" in go_shape: + mismatches.extend(compare_shapes(node_shape["array"], go_shape["array"], path + "[0]")) + return mismatches + + # Object: check Node keys exist in Go + for key in node_shape: + if key not in go_shape: + mismatches.append(f"{path}: Go missing field \"{key}\" (Node has it)") + else: + mismatches.extend(compare_shapes(node_shape[key], go_shape[key], f"{path}.{key}")) + + # Check Go has extra keys not in Node (warning only) + for key in go_shape: + if key not in node_shape: + mismatches.append(f"{path}: Go has extra field \"{key}\" (not in Node) [WARN]") + + return mismatches + +try: + node_json = json.loads(sys.argv[1]) + go_json = json.loads(sys.argv[2]) +except (json.JSONDecodeError, IndexError) as e: + print(f"JSON parse error: {e}", file=sys.stderr) + sys.exit(2) + +node_shape = extract_shape(node_json) +go_shape = extract_shape(go_json) + +mismatches = compare_shapes(node_shape, go_shape) +if mismatches: + for m in mismatches: + print(m) + sys.exit(1) +else: + sys.exit(0) +' + +echo "============================================" +echo " Node.js vs Go API Parity Check" +echo " Node: ${NODE_BASE} | Go: ${GO_BASE}" +echo "============================================" +echo "" + +for ep in "${ENDPOINTS[@]}"; do + printf "%-50s " "$ep" + + # Fetch Node response + node_resp=$(curl -sf "${NODE_BASE}${ep}" 2>/dev/null) || { + printf "${YELLOW}SKIP${NC} (Node unreachable)\n" + SKIP=$((SKIP + 1)) + continue + } + + # Fetch Go response + go_resp=$(curl -sf "${GO_BASE}${ep}" 2>/dev/null) || { + printf "${YELLOW}SKIP${NC} (Go unreachable)\n" + SKIP=$((SKIP + 1)) + continue + } + + # Compare shapes + result=$(python3 -c "$SHAPE_SCRIPT" "$node_resp" "$go_resp" 2>&1) || { + printf "${RED}FAIL${NC}\n" + echo "$result" | sed 's/^/ /' + FAIL=$((FAIL + 1)) + continue + } + + printf "${GREEN}PASS${NC}\n" + PASS=$((PASS + 1)) +done + +echo "" +echo "============================================" +echo " Results: ${PASS} pass, ${FAIL} fail, ${SKIP} skip" +echo "============================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0