mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-31 16:55:48 +00:00
- 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>
404 lines
13 KiB
Go
404 lines
13 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|