Files
explorer/realtime_test.go
zeekay bf584f6b48 feat: runtime chain registry, mDNS discovery, /v1/explorer/realtime
Runtime-mutable chain registry with admin CRUD API, mDNS auto-discovery
of local luxd nodes, and multi-chain WebSocket realtime subscriptions.

Config sources (priority order):
  1. Admin API (POST/PUT/DELETE /v1/explorer/admin/chains)
  2. --config chains.yaml
  3. CHAINS env var
  4. mDNS _luxd._tcp discovery (lowest, never overrides manual)

21 tests, all passing.
2026-04-13 01:36:31 -07:00

202 lines
5.0 KiB
Go

package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestRealtimeHubStats(t *testing.T) {
hub := NewRealtimeHub()
stats := hub.Stats()
if stats["connections"] != 0 {
t.Fatalf("expected 0 connections, got %d", stats["connections"])
}
}
func TestRealtimeWebSocket(t *testing.T) {
hub := NewRealtimeHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
ts := httptest.NewServer(mux)
defer ts.Close()
// Connect WebSocket
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Read connected message
var msg RealtimeMessage
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read connected: %v", err)
}
if msg.Type != "connected" {
t.Fatalf("expected connected, got %s", msg.Type)
}
// Subscribe to blocks
sub := SubscribeRequest{Subscribe: "blocks", Chain: "cchain"}
conn.WriteJSON(sub)
// Read subscribed confirmation
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read subscribed: %v", err)
}
if msg.Type != "subscribed" {
t.Fatalf("expected subscribed, got %s", msg.Type)
}
// Broadcast a block
hub.Broadcast("blocks", "cchain", map[string]any{"number": 1})
// Read the broadcast
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read broadcast: %v", err)
}
if msg.Type != "blocks" {
t.Fatalf("expected blocks event, got %s", msg.Type)
}
if msg.Chain != "cchain" {
t.Fatalf("expected chain=cchain, got %s", msg.Chain)
}
// Verify stats
stats := hub.Stats()
if stats["connections"] != 1 {
t.Fatalf("expected 1 connection, got %d", stats["connections"])
}
if stats["subscriptions"] != 1 {
t.Fatalf("expected 1 subscription, got %d", stats["subscriptions"])
}
}
func TestRealtimePing(t *testing.T) {
hub := NewRealtimeHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
ts := httptest.NewServer(mux)
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Consume connected
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.ReadJSON(&RealtimeMessage{})
// Send ping
conn.WriteJSON(SubscribeRequest{Subscribe: "ping"})
var msg RealtimeMessage
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read pong: %v", err)
}
if msg.Type != "pong" {
t.Fatalf("expected pong, got %s", msg.Type)
}
}
func TestRealtimeUnscopedSubscription(t *testing.T) {
hub := NewRealtimeHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
ts := httptest.NewServer(mux)
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Consume connected
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.ReadJSON(&RealtimeMessage{})
// Subscribe to transactions without chain scope (gets all chains)
conn.WriteJSON(SubscribeRequest{Subscribe: "transactions"})
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.ReadJSON(&RealtimeMessage{}) // subscribed
// Broadcast from any chain should match
hub.Broadcast("transactions", "zoo", map[string]any{"hash": "0xabc"})
var msg RealtimeMessage
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read broadcast: %v", err)
}
if msg.Type != "transactions" {
t.Fatalf("expected transactions, got %s", msg.Type)
}
if msg.Chain != "zoo" {
t.Fatalf("expected chain=zoo, got %s", msg.Chain)
}
}
func TestRealtimeStatsEndpoint(t *testing.T) {
hub := NewRealtimeHub()
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/explorer/realtime/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hub.Stats())
})
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := ts.Client().Get(ts.URL + "/v1/explorer/realtime/stats")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var stats map[string]int
json.NewDecoder(resp.Body).Decode(&stats)
if stats["connections"] != 0 {
t.Fatalf("expected 0 connections, got %d", stats["connections"])
}
}