mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-29 08:29:55 +00:00
Add golden fixture parity test suite — Go must match Node shapes
- 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>
This commit is contained in:
403
cmd/server/parity_test.go
Normal file
403
cmd/server/parity_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
1566
cmd/server/testdata/golden/shapes.json
vendored
Normal file
1566
cmd/server/testdata/golden/shapes.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
179
tools/check-parity.sh
Normal file
179
tools/check-parity.sh
Normal file
@@ -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@<VM_HOST> '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
|
||||
Reference in New Issue
Block a user