spaxel/mothership/internal/fleet/handler_test.go
jedarden d048987d61
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
feat(ingestion): persist hello-announced node position in fleet registry (bf-24xp)
spaxel-sim computed good corner/perimeter geometry in createVirtualNodes but
the hello message sent on WebSocket connect did not include position, so the
mothership never learned the sim node's location and the fleet registry/DB row
was left at the nodes-table schema default (0,0,1) — co-located with every
other node.

Both sim CLIs (cmd/sim and mothership/cmd/sim) now announce pos_x/pos_y/pos_z
in the hello handshake, sourced from createVirtualNodes. HelloMessage gains
PosX/PosY/PosZ as *float64 (not plain float64) so an absent position is
distinguishable from a genuine (0,0,0): a real ESP32 omits these in hello
(its position is user-placed in the dashboard), whereas spaxel-sim announces
its computed geometry.

The connect/register path (server.HandleNodeWS -> FleetNotifier.OnNodeConnected
-> fleet.Manager) now carries the announced position through. The Manager
persists it via registry.SetNodePosition only when all three axes are present,
so a nil/partial announcement preserves any existing user-placed position
rather than clobbering it. The position then flows onward to the fusion engine
through the bf-3p6g connect/register sink (ForwardNodePosition ->
nodePositionSink -> fusionEngine.SetNodePosition), so a freshly connected sim
node is seeded at its announced location instead of (0,0,1).

The FleetNotifier interface change (OnNodeConnected gains three *float64
params) is propagated to every implementer — Manager (persists), FleetHealer
and SelfHealManager (read geometry from the registry, ignore), the guided-
troubleshoot notifier (tracks connect/disconnect only), and the cmd/mothership
multiFleetNotifier fan-out.

Tests: existing OnNodeConnected call sites updated to the new signature.
TestManagerOnNodeConnectedPersistsHelloPosition asserts a node connecting with
an announced position yields a registry row whose pos differs from (0,0,1) and
matches the sent coordinates, and that the same coordinates reach the fusion
engine. TestManagerOnNodeConnectedWithoutHelloPositionPreservesExisting
asserts the real-ESP32 case (nil announce) and partial-announce both leave an
existing user-placed position untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 18:42:34 -04:00

2766 lines
75 KiB
Go

package fleet
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/fusion"
)
// mockNodeIdentifier is a mock implementation of NodeIdentifier for testing.
type mockNodeIdentifier struct {
sendIdentifyFunc func(mac string, durationMS int) bool
sendRebootFunc func(mac string, delayMS int) bool
getConnectedMACs func() []string
getUnpairedMACs func() []string
}
func (m *mockNodeIdentifier) SendIdentifyToMAC(mac string, durationMS int) bool {
if m.sendIdentifyFunc != nil {
return m.sendIdentifyFunc(mac, durationMS)
}
return true
}
func (m *mockNodeIdentifier) SendRebootToMAC(mac string, delayMS int) bool {
if m.sendRebootFunc != nil {
return m.sendRebootFunc(mac, delayMS)
}
return true
}
func (m *mockNodeIdentifier) GetConnectedMACs() []string {
if m.getConnectedMACs != nil {
return m.getConnectedMACs()
}
return []string{}
}
func (m *mockNodeIdentifier) GetUnpairedMACs() []string {
if m.getUnpairedMACs != nil {
return m.getUnpairedMACs()
}
return []string{}
}
// mockMigProvider is a mock MigrationDeadlineProvider for testing.
type mockMigProvider struct {
deadline time.Time
}
func (m *mockMigProvider) GetMigrationDeadline() time.Time {
return m.deadline
}
// fleetListResp mirrors fleetListResponse for test decoding.
type fleetListResp struct {
Nodes []FleetNode `json:"nodes"`
}
// fleetListFullResp mirrors fleetListResponse with migration window fields.
type fleetListFullResp struct {
Nodes []FleetNode `json:"nodes"`
MigrationWindowActive bool `json:"migration_window_active"`
MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"`
MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"`
}
// mockRegistry is a mock implementation of Registry for testing.
type mockRegistry struct {
nodes map[string]NodeRecord
}
func (m *mockRegistry) GetNode(mac string) (*NodeRecord, error) {
if node, ok := m.nodes[mac]; ok {
return &node, nil
}
return nil, sql.ErrNoRows
}
func (m *mockRegistry) GetAllNodes() ([]NodeRecord, error) {
var nodes []NodeRecord
for _, node := range m.nodes {
nodes = append(nodes, node)
}
return nodes, nil
}
func (m *mockRegistry) SetNodeLabel(mac, label string) error {
if node, ok := m.nodes[mac]; ok {
node.Name = label
m.nodes[mac] = node
return nil
}
return sql.ErrNoRows
}
func (m *mockRegistry) SetNodePosition(mac string, x, y, z float64) error {
if node, ok := m.nodes[mac]; ok {
node.PosX = x
node.PosY = y
node.PosZ = z
m.nodes[mac] = node
return nil
}
return sql.ErrNoRows
}
func (m *mockRegistry) AddVirtualNode(mac, name string, x, y, z float64) error {
m.nodes[mac] = NodeRecord{
MAC: mac,
Name: name,
Role: "virtual",
PosX: x,
PosY: y,
PosZ: z,
Virtual: true,
}
return nil
}
func (m *mockRegistry) DeleteNode(mac string) error {
delete(m.nodes, mac)
return nil
}
func (m *mockRegistry) UpsertNode(mac, firmware, chip string) error {
if _, ok := m.nodes[mac]; !ok {
m.nodes[mac] = NodeRecord{
MAC: mac,
Name: "",
Role: "rx",
FirmwareVersion: firmware,
ChipModel: chip,
}
} else {
node := m.nodes[mac]
node.FirmwareVersion = firmware
node.ChipModel = chip
m.nodes[mac] = node
}
return nil
}
func (m *mockRegistry) SetNodeRole(mac, role string) error {
if node, ok := m.nodes[mac]; ok {
node.Role = role
m.nodes[mac] = node
return nil
}
return sql.ErrNoRows
}
func (m *mockRegistry) Close() error {
return nil
}
func TestHandlerIdentifyNode(t *testing.T) {
tests := []struct {
name string
mac string
reqBody string
nodeExists bool
nodeConnected bool
wantStatus int
wantResponse string
}{
{
name: "successful identify with default duration",
mac: "AA:BB:CC:DD:EE:FF",
reqBody: `{}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
wantResponse: "{\"ok\":true}\n",
},
{
name: "successful identify with custom duration",
mac: "AA:BB:CC:DD:EE:FF",
reqBody: `{"duration_ms": 10000}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
wantResponse: "{\"ok\":true}\n",
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
reqBody: `{}`,
nodeExists: false,
nodeConnected: true,
wantStatus: http.StatusNotFound,
wantResponse: "node not found\n",
},
{
name: "node not connected",
mac: "AA:BB:CC:DD:EE:FF",
reqBody: `{}`,
nodeExists: true,
nodeConnected: false,
wantStatus: http.StatusNotFound,
wantResponse: "node not connected\n",
},
{
name: "invalid request body",
mac: "AA:BB:CC:DD:EE:FF",
reqBody: `invalid json`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusBadRequest,
wantResponse: "invalid request body\n",
},
{
name: "zero duration uses default",
mac: "AA:BB:CC:DD:EE:FF",
reqBody: `{"duration_ms": 0}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
wantResponse: "{\"ok\":true}\n",
},
{
name: "negative duration uses default",
mac: "AA:BB:CC:DD:EE:FF",
reqBody: `{"duration_ms": -1000}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
wantResponse: "{\"ok\":true}\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a real registry with the test node
reg := newTestRegistry(t)
if tt.nodeExists {
err := reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
if err != nil {
t.Fatalf("UpsertNode: %v", err)
}
}
// Create a manager with the registry
mgr := NewManager(reg)
// Create handler with mock node identifier
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
sendIdentifyFunc: func(mac string, durationMS int) bool {
return tt.nodeConnected
},
},
}
// Create a test request
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/identify", bytes.NewBufferString(tt.reqBody))
req.Header.Set("Content-Type", "application/json")
// Use chi URLParam to set the MAC parameter
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
// Create response recorder
w := httptest.NewRecorder()
// Call the handler
h.identifyNode(w, req)
// Check status code
if w.Code != tt.wantStatus {
t.Errorf("identifyNode() status = %v, want %v", w.Code, tt.wantStatus)
}
// Check response body
if tt.wantResponse != "" {
resp := w.Body.String()
if resp != tt.wantResponse {
t.Errorf("identifyNode() response = %q, want %q", resp, tt.wantResponse)
}
}
})
}
}
func TestHandlerIdentifyNodeDurationParsing(t *testing.T) {
tests := []struct {
name string
reqBody string
expectedDuration int
}{
{
name: "default duration when not specified",
reqBody: `{}`,
expectedDuration: 5000,
},
{
name: "custom duration",
reqBody: `{"duration_ms": 10000}`,
expectedDuration: 10000,
},
{
name: "zero uses default",
reqBody: `{"duration_ms": 0}`,
expectedDuration: 5000,
},
{
name: "negative uses default",
reqBody: `{"duration_ms": -1000}`,
expectedDuration: 5000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var actualDuration int
reg := newTestRegistry(t)
err := reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
if err != nil {
t.Fatalf("UpsertNode: %v", err)
}
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
sendIdentifyFunc: func(mac string, durationMS int) bool {
actualDuration = durationMS
return true
},
},
}
req := httptest.NewRequest("POST", "/api/nodes/AA:BB:CC:DD:EE:FF/identify", bytes.NewBufferString(tt.reqBody))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", "AA:BB:CC:DD:EE:FF")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.identifyNode(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status OK, got %v", w.Code)
}
if actualDuration != tt.expectedDuration {
t.Errorf("Duration = %v, want %v", actualDuration, tt.expectedDuration)
}
})
}
}
func TestIdentifyNodeRequest(t *testing.T) {
tests := []struct {
name string
json string
wantErr bool
}{
{
name: "valid empty object",
json: `{}`,
wantErr: false,
},
{
name: "valid with duration",
json: `{"duration_ms": 10000}`,
wantErr: false,
},
{
name: "valid with zero duration",
json: `{"duration_ms": 0}`,
wantErr: false,
},
{
name: "valid with negative duration",
json: `{"duration_ms": -1000}`,
wantErr: false,
},
{
name: "invalid json",
json: `invalid`,
wantErr: true,
},
{
name: "extra fields ignored",
json: `{"duration_ms": 5000, "extra": "ignored"}`,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req identifyNodeRequest
err := json.NewDecoder(bytes.NewBufferString(tt.json)).Decode(&req)
if (err != nil) != tt.wantErr {
t.Errorf("json.NewDecoder().Decode() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ─── System mode endpoint tests ─────────────────────────────────────────────
func TestHandlerGetSystemMode(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("GET", "/api/mode", nil)
w := httptest.NewRecorder()
h.getSystemMode(w, req)
if w.Code != http.StatusOK {
t.Errorf("getSystemMode() status = %v, want %v", w.Code, http.StatusOK)
}
var resp systemModeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Mode != "home" {
t.Errorf("Expected mode to be home, got %s", resp.Mode)
}
if !resp.AutoAwayConfig.Enabled {
t.Errorf("Expected auto-away to be enabled by default")
}
if resp.AutoAwayConfig.AbsenceDurationSec != 900 { // 15 minutes
t.Errorf("Expected absence duration to be 900s, got %d", resp.AutoAwayConfig.AbsenceDurationSec)
}
}
func TestHandlerSetSystemMode(t *testing.T) {
tests := []struct {
name string
requestBody string
wantStatus int
expectedMode string
}{
{
name: "set to away mode",
requestBody: `{"mode": "away", "reason": "manual"}`,
wantStatus: http.StatusOK,
expectedMode: "away",
},
{
name: "set to home mode",
requestBody: `{"mode": "home", "reason": "manual"}`,
wantStatus: http.StatusOK,
expectedMode: "home",
},
{
name: "set to sleep mode",
requestBody: `{"mode": "sleep", "reason": "night"}`,
wantStatus: http.StatusOK,
expectedMode: "sleep",
},
{
name: "mode defaults reason to manual",
requestBody: `{"mode": "away"}`,
wantStatus: http.StatusOK,
expectedMode: "away",
},
{
name: "invalid mode",
requestBody: `{"mode": "invalid"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "invalid json",
requestBody: `invalid json`,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("POST", "/api/mode", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.setSystemMode(w, req)
if w.Code != tt.wantStatus {
t.Errorf("setSystemMode() status = %v, want %v", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK {
var resp systemModeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Mode != tt.expectedMode {
t.Errorf("Expected mode to be %s, got %s", tt.expectedMode, resp.Mode)
}
}
})
}
}
// ─── Fleet list endpoint tests ────────────────────────────────────────────────
func TestHandlerListFleet(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 1.0, 2.0, 3.0)
reg.UpsertNode("11:22:33:44:55:66", "1.1.0", "ESP32-S3")
reg.SetNodeLabel("11:22:33:44:55:66", "Node 2")
reg.SetNodePosition("11:22:33:44:55:66", 4.0, 5.0, 6.0)
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
sendIdentifyFunc: func(mac string, durationMS int) bool {
return true
},
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
nodes := resp.Nodes
if len(nodes) != 2 {
t.Errorf("Expected 2 nodes, got %d", len(nodes))
}
// Check first node (should be online)
if nodes[0].MAC != "AA:BB:CC:DD:EE:FF" {
t.Errorf("Expected first node MAC to be AA:BB:CC:DD:EE:FF, got %s", nodes[0].MAC)
}
if nodes[0].Status != "online" {
t.Errorf("Expected first node status to be online, got %s", nodes[0].Status)
}
// Check second node (should be offline - not in connected list)
if nodes[1].MAC != "11:22:33:44:55:66" {
t.Errorf("Expected second node MAC to be 11:22:33:44:55:66, got %s", nodes[1].MAC)
}
if nodes[1].Status != "offline" {
t.Errorf("Expected second node status to be offline, got %s", nodes[1].Status)
}
}
func TestHandlerListFleetEmpty(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if len(resp.Nodes) != 0 {
t.Errorf("Expected 0 nodes, got %d", len(resp.Nodes))
}
}
// ─── Get node endpoint tests ───────────────────────────────────────────────────
func TestHandlerGetNode(t *testing.T) {
tests := []struct {
name string
mac string
nodeExists bool
wantStatus int
}{
{
name: "node found",
mac: "AA:BB:CC:DD:EE:FF",
nodeExists: true,
wantStatus: http.StatusOK,
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
nodeExists: false,
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Test Node")
}
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("GET", "/api/nodes/"+tt.mac, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.getNode(w, req)
if w.Code != tt.wantStatus {
t.Errorf("getNode() status = %v, want %v", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK {
var node NodeRecord
if err := json.NewDecoder(w.Body).Decode(&node); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if node.MAC != tt.mac {
t.Errorf("Expected MAC to be %s, got %s", tt.mac, node.MAC)
}
}
})
}
}
// ─── Update node label endpoint tests ───────────────────────────────────────────
func TestHandlerUpdateNodeLabel(t *testing.T) {
tests := []struct {
name string
mac string
requestBody string
nodeExists bool
wantStatus int
expectedLabel string
}{
{
name: "successful label update",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"label": "New Label"}`,
nodeExists: true,
wantStatus: http.StatusNoContent,
expectedLabel: "New Label",
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"label": "New Label"}`,
nodeExists: false,
wantStatus: http.StatusNotFound,
},
{
name: "invalid request body",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `invalid json`,
nodeExists: true,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Old Label")
}
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("PATCH", "/api/nodes/"+tt.mac+"/label", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.updateNodeLabel(w, req)
if w.Code != tt.wantStatus {
t.Errorf("updateNodeLabel() status = %v, want %v", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusNoContent {
// Verify the label was updated
node, err := reg.GetNode(tt.mac)
if err != nil {
t.Errorf("Failed to get node: %v", err)
} else if node.Name != tt.expectedLabel {
t.Errorf("Expected label to be %s, got %s", tt.expectedLabel, node.Name)
}
}
})
}
}
// ─── Set node role endpoint tests ───────────────────────────────────────────────
func TestHandlerSetNodeRole(t *testing.T) {
tests := []struct {
name string
mac string
requestBody string
nodeExists bool
wantStatus int
expectedRole string
}{
{
name: "successful role change to tx",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"role": "tx"}`,
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "tx",
},
{
name: "successful role change to rx",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"role": "rx"}`,
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "rx",
},
{
name: "successful role change to tx_rx",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"role": "tx_rx"}`,
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "tx_rx",
},
{
name: "successful role change to passive",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"role": "passive"}`,
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "passive",
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"role": "tx"}`,
nodeExists: false,
wantStatus: http.StatusNotFound,
},
{
name: "invalid role",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"role": "invalid"}`,
nodeExists: true,
wantStatus: http.StatusBadRequest,
},
{
name: "invalid request body",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `invalid json`,
nodeExists: true,
wantStatus: http.StatusBadRequest,
},
{
name: "empty role",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"role": ""}`,
nodeExists: true,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Test Node")
reg.SetNodeRole(tt.mac, "rx")
}
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/role", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.setNodeRole(w, req)
if w.Code != tt.wantStatus {
t.Errorf("setNodeRole() status = %v, want %v", w.Code, tt.wantStatus)
}
})
}
}
// ─── Delete node endpoint tests ─────────────────────────────────────────────────
func TestHandlerDeleteNode(t *testing.T) {
tests := []struct {
name string
mac string
nodeExists bool
wantStatus int
}{
{
name: "successful deletion",
mac: "AA:BB:CC:DD:EE:FF",
nodeExists: true,
wantStatus: http.StatusNoContent,
},
{
name: "delete non-existent node succeeds",
mac: "AA:BB:CC:DD:EE:FF",
nodeExists: false,
wantStatus: http.StatusNoContent,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Test Node")
reg.SetNodeRole(tt.mac, "rx")
}
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("DELETE", "/api/nodes/"+tt.mac, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.deleteNode(w, req)
if w.Code != tt.wantStatus {
t.Errorf("deleteNode() status = %v, want %v", w.Code, tt.wantStatus)
}
})
}
}
// ─── OTA endpoint tests ───────────────────────────────────────────────────────────
func TestHandlerTriggerNodeOTA(t *testing.T) {
tests := []struct {
name string
mac string
requestBody string
nodeExists bool
otaAvailable bool
wantStatus int
}{
{
name: "successful OTA trigger",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{}`,
nodeExists: true,
otaAvailable: true,
wantStatus: http.StatusOK,
},
{
name: "OTA with specific version",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"version": "1.2.0"}`,
nodeExists: true,
otaAvailable: true,
wantStatus: http.StatusOK,
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{}`,
nodeExists: false,
otaAvailable: true,
wantStatus: http.StatusNotFound,
},
{
name: "invalid request body",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `invalid json`,
nodeExists: true,
otaAvailable: true,
wantStatus: http.StatusBadRequest,
},
{
name: "OTA manager not available",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{}`,
nodeExists: true,
otaAvailable: false,
wantStatus: http.StatusOK, // Still succeeds without OTA manager
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Test Node")
reg.SetNodeRole(tt.mac, "rx")
}
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
if tt.otaAvailable {
// OTA manager is optional in the handler
h.otaMgr = nil // Mock would go here
}
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/ota", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.triggerNodeOTA(w, req)
if w.Code != tt.wantStatus {
t.Errorf("triggerNodeOTA() status = %v, want %v", w.Code, tt.wantStatus)
}
})
}
}
// ─── Reboot endpoint tests ─────────────────────────────────────────────────────────
func TestHandlerRebootNode(t *testing.T) {
tests := []struct {
name string
mac string
requestBody string
nodeExists bool
nodeConnected bool
wantStatus int
}{
{
name: "successful reboot with default delay",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
},
{
name: "successful reboot with custom delay",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"delay_ms": 5000}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{}`,
nodeExists: false,
nodeConnected: true,
wantStatus: http.StatusNotFound,
},
{
name: "node not connected",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{}`,
nodeExists: true,
nodeConnected: false,
wantStatus: http.StatusNotFound,
},
{
name: "invalid request body",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `invalid json`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusBadRequest,
},
{
name: "zero delay uses default",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"delay_ms": 0}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
},
{
name: "negative delay uses default",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"delay_ms": -1000}`,
nodeExists: true,
nodeConnected: true,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Test Node")
reg.SetNodeRole(tt.mac, "rx")
}
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
sendRebootFunc: func(mac string, delayMS int) bool {
return tt.nodeConnected
},
},
}
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/reboot", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.rebootNode(w, req)
if w.Code != tt.wantStatus {
t.Errorf("rebootNode() status = %v, want %v", w.Code, tt.wantStatus)
}
})
}
}
// ─── Update all endpoint tests ────────────────────────────────────────────────────
func TestHandlerUpdateAllNodes(t *testing.T) {
tests := []struct {
name string
connectedMACs []string
wantStatus int
expectedCount int
}{
{
name: "update all connected nodes",
connectedMACs: []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"},
wantStatus: http.StatusOK,
expectedCount: 2,
},
{
name: "no connected nodes",
connectedMACs: []string{},
wantStatus: http.StatusOK,
expectedCount: 0,
},
{
name: "single connected node",
connectedMACs: []string{"AA:BB:CC:DD:EE:FF"},
wantStatus: http.StatusOK,
expectedCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return tt.connectedMACs
},
},
}
req := httptest.NewRequest("POST", "/api/nodes/update-all", nil)
w := httptest.NewRecorder()
h.updateAllNodes(w, req)
if w.Code != tt.wantStatus {
t.Errorf("updateAllNodes() status = %v, want %v", w.Code, tt.wantStatus)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
count, ok := resp["count"].(float64)
if !ok {
t.Fatalf("Expected count to be a number, got %T", resp["count"])
}
if int(count) != tt.expectedCount {
t.Errorf("Expected count to be %d, got %d", tt.expectedCount, int(count))
}
})
}
}
// ─── Export/Import endpoint tests ─────────────────────────────────────────────────
func TestHandlerExportConfig(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
reg.UpsertNode("11:22:33:44:55:66", "1.1.0", "ESP32-S3")
reg.SetNodeLabel("11:22:33:44:55:66", "Node 2")
reg.SetNodeRole("11:22:33:44:55:66", "tx")
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("GET", "/api/export", nil)
w := httptest.NewRecorder()
h.exportConfig(w, req)
if w.Code != http.StatusOK {
t.Errorf("exportConfig() status = %v, want %v", w.Code, http.StatusOK)
}
var config map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&config); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
// Check version
if config["version"] != float64(1) {
t.Errorf("Expected version to be 1, got %v", config["version"])
}
// Check exported_at exists
if config["exported_at"] == nil {
t.Error("Expected exported_at to be present")
}
// Check nodes
nodes, ok := config["nodes"].([]interface{})
if !ok {
t.Fatalf("Expected nodes to be an array, got %T", config["nodes"])
}
if len(nodes) != 2 {
t.Errorf("Expected 2 nodes, got %d", len(nodes))
}
}
func TestHandlerImportConfig(t *testing.T) {
tests := []struct {
name string
requestBody string
wantStatus int
}{
{
name: "valid import config",
requestBody: `{"version": 1, "nodes": []}`,
wantStatus: http.StatusOK,
},
{
name: "valid import with nodes",
requestBody: `{"version": 1, "nodes": [{"mac": "AA:BB:CC:DD:EE:FF", "name": "Test"}]}`,
wantStatus: http.StatusOK,
},
{
name: "invalid json",
requestBody: `invalid json`,
wantStatus: http.StatusBadRequest,
},
{
name: "empty object",
requestBody: `{}`,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("POST", "/api/import", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.importConfig(w, req)
if w.Code != tt.wantStatus {
t.Errorf("importConfig() status = %v, want %v", w.Code, tt.wantStatus)
}
})
}
}
// ─── Rebaseline endpoint tests ────────────────────────────────────────────────────
func TestHandlerRebaselineAllNodes(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("POST", "/api/nodes/rebaseline-all", nil)
w := httptest.NewRecorder()
h.rebaselineAllNodes(w, req)
if w.Code != http.StatusOK {
t.Errorf("rebaselineAllNodes() status = %v, want %v", w.Code, http.StatusOK)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if resp["ok"] != true {
t.Errorf("Expected ok to be true, got %v", resp["ok"])
}
}
// ─── Add virtual node endpoint tests ───────────────────────────────────────────────
func TestHandlerAddVirtualNode(t *testing.T) {
tests := []struct {
name string
requestBody string
wantStatus int
}{
{
name: "successful virtual node creation",
requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`,
wantStatus: http.StatusCreated,
},
{
name: "missing MAC address",
requestBody: `{"name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`,
wantStatus: http.StatusBadRequest,
},
{
name: "invalid json",
requestBody: `invalid json`,
wantStatus: http.StatusBadRequest,
},
{
name: "empty body",
requestBody: `{}`,
wantStatus: http.StatusBadRequest,
},
{
name: "negative coordinates",
requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": -1.0, "y": -2.0, "z": -3.0}`,
wantStatus: http.StatusCreated,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("POST", "/api/nodes/virtual", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.addVirtualNode(w, req)
if w.Code != tt.wantStatus {
t.Errorf("addVirtualNode() status = %v, want %v", w.Code, tt.wantStatus)
}
})
}
}
// ─── Update node position endpoint tests ────────────────────────────────────────
func TestHandlerUpdateNodePosition(t *testing.T) {
tests := []struct {
name string
mac string
requestBody string
wantStatus int
expectedX float64
expectedY float64
expectedZ float64
}{
{
name: "successful position update",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"x": 1.5, "y": 2.5, "z": 3.5}`,
wantStatus: http.StatusNoContent,
expectedX: 1.5,
expectedY: 2.5,
expectedZ: 3.5,
},
{
name: "negative coordinates",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"x": -1.0, "y": -2.0, "z": -3.0}`,
wantStatus: http.StatusNoContent,
expectedX: -1.0,
expectedY: -2.0,
expectedZ: -3.0,
},
{
name: "zero coordinates",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `{"x": 0, "y": 0, "z": 0}`,
wantStatus: http.StatusNoContent,
expectedX: 0,
expectedY: 0,
expectedZ: 0,
},
{
name: "invalid request body",
mac: "AA:BB:CC:DD:EE:FF",
requestBody: `invalid json`,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Test Node")
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 0, 0, 0)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("PUT", "/api/nodes/"+tt.mac+"/position", bytes.NewBufferString(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.updateNodePosition(w, req)
if w.Code != tt.wantStatus {
t.Errorf("updateNodePosition() status = %v, want %v", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusNoContent {
// Verify the position was updated
node, err := reg.GetNode(tt.mac)
if err != nil {
t.Errorf("Failed to get node: %v", err)
} else if node.PosX != tt.expectedX || node.PosY != tt.expectedY || node.PosZ != tt.expectedZ {
t.Errorf("Expected position to be (%v, %v, %v), got (%v, %v, %v)",
tt.expectedX, tt.expectedY, tt.expectedZ,
node.PosX, node.PosY, node.PosZ)
}
}
})
}
}
// engineNodePosition returns the fusion engine's stored position for mac, or
// nil if the engine holds no position for it. The engine exposes its node
// registry only via NodePositions() (a snapshot slice), so the helper scans.
func engineNodePosition(e *fusion.Engine, mac string) *fusion.NodePosition {
for _, p := range e.NodePositions() {
if p.MAC == mac {
cp := p
return &cp
}
}
return nil
}
// patchNodePosition issues a PUT /api/nodes/{mac}/position through the handler
// and fails the test if the handler does not return 204 No Content.
func patchNodePosition(t *testing.T, h *Handler, mac, body string) {
t.Helper()
req := httptest.NewRequest("PUT", "/api/nodes/"+mac+"/position", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.updateNodePosition(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("updateNodePosition status = %v, want %d (body=%q)", w.Code, http.StatusNoContent, w.Body.String())
}
}
// TestHandlerUpdateNodePositionForwardsToEngine (bf-3p6g) verifies that a
// PATCH /api/nodes/{mac}/position flows through the handler and the manager's
// node position sink into the blob-producing fusion engine, and that the
// position the engine stores for that MAC changes to the new coordinates.
func TestHandlerUpdateNodePositionForwardsToEngine(t *testing.T) {
const mac = "AA:BB:CC:DD:EE:FF"
reg := newTestRegistry(t)
reg.UpsertNode(mac, "1.0.0", "ESP32-S3")
reg.SetNodeRole(mac, "rx")
reg.SetNodePosition(mac, 0, 0, 0)
engine := fusion.NewEngine(nil)
mgr := NewManager(reg)
mgr.SetNodePositionSink(func(m string, x, y, z float64) {
engine.SetNodePosition(m, x, y, z)
})
h := &Handler{mgr: mgr}
// Sanity: engine holds no position for this MAC before any PATCH.
if p := engineNodePosition(engine, mac); p != nil {
t.Fatalf("engine should hold no position before PATCH, got (%v,%v,%v)", p.X, p.Y, p.Z)
}
// First PATCH seeds the engine position.
patchNodePosition(t, h, mac, `{"x": 1.5, "y": 2.5, "z": 3.5}`)
p := engineNodePosition(engine, mac)
if p == nil {
t.Fatal("PATCH did not forward node position to fusion engine")
}
if p.X != 1.5 || p.Y != 2.5 || p.Z != 3.5 {
t.Errorf("engine position after PATCH = (%v,%v,%v), want (1.5,2.5,3.5)", p.X, p.Y, p.Z)
}
// Second PATCH with new coordinates: the stored engine position must change.
patchNodePosition(t, h, mac, `{"x": 7.0, "y": 8.0, "z": 9.0}`)
p = engineNodePosition(engine, mac)
if p == nil {
t.Fatal("engine lost position after second PATCH")
}
if p.X != 7.0 || p.Y != 8.0 || p.Z != 9.0 {
t.Errorf("engine position after second PATCH = (%v,%v,%v), want (7,8,9)", p.X, p.Y, p.Z)
}
}
// TestManagerOnNodeConnectedForwardsPositionToEngine (bf-3p6g) verifies that a
// node connect/register event pushes the node's current registry position into
// the fusion engine, so a node positioned (or moved) while offline does not
// keep a stale engine position when it reconnects.
func TestManagerOnNodeConnectedForwardsPositionToEngine(t *testing.T) {
const mac = "AA:BB:CC:DD:EE:FF"
reg := newTestRegistry(t)
// Pre-position the node in the registry (e.g. placed while offline), then
// connect it; the manager must forward the registry position to the engine.
reg.UpsertNode(mac, "1.0.0", "ESP32-S3")
reg.SetNodePosition(mac, 4.0, 5.0, 6.0)
engine := fusion.NewEngine(nil)
mgr := NewManager(reg)
mgr.SetNodePositionSink(func(m string, x, y, z float64) {
engine.SetNodePosition(m, x, y, z)
})
mgr.OnNodeConnected(mac, "1.0.0", "ESP32-S3", nil, nil, nil)
p := engineNodePosition(engine, mac)
if p == nil {
t.Fatal("OnNodeConnected did not forward node position to fusion engine")
}
if p.X != 4.0 || p.Y != 5.0 || p.Z != 6.0 {
t.Errorf("engine position after connect = (%v,%v,%v), want (4,5,6)", p.X, p.Y, p.Z)
}
}
// TestManagerOnNodeConnectedPersistsHelloPosition (bf-24xp) verifies the
// acceptance criterion for hello-announced node positions: when a node
// connects announcing its 3D position (spaxel-sim does this with its computed
// corner/perimeter geometry), the manager persists it to the registry so the
// fleet/DB row is NOT left at the schema default (0,0,1) and matches the
// announced coordinates. The announced position must also reach the fusion
// engine through the bf-3p6g connect/register sink.
func TestManagerOnNodeConnectedPersistsHelloPosition(t *testing.T) {
f64 := func(v float64) *float64 { return &v }
tests := []struct {
name string
posX, posY, posZ float64
}{
{name: "corner_geometry", posX: 6.0, posY: 0.0, posZ: 2.5},
{name: "interior_point", posX: 3.25, posY: 2.75, posZ: 1.1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
const mac = "02:53:AC:00:00:01" // matches spaxel-sim's MAC OUI
reg := newTestRegistry(t)
engine := fusion.NewEngine(nil)
mgr := NewManager(reg)
mgr.SetNodePositionSink(func(m string, x, y, z float64) {
engine.SetNodePosition(m, x, y, z)
})
// Connect announcing the position, exactly as spaxel-sim does in hello.
mgr.OnNodeConnected(mac, "sim-1.0.0", "ESP32-S3", f64(tc.posX), f64(tc.posY), f64(tc.posZ))
// Registry row must hold the announced position, not the (0,0,1) default.
rx, ry, rz, ok := reg.GetNodePosition(mac)
if !ok {
t.Fatal("node row missing after connect")
}
if rx == 0 && ry == 0 && rz == 1 {
t.Errorf("registry position still at schema default (0,0,1); got (%v,%v,%v)", rx, ry, rz)
}
if rx != tc.posX || ry != tc.posY || rz != tc.posZ {
t.Errorf("registry position = (%v,%v,%v), want (%v,%v,%v)", rx, ry, rz, tc.posX, tc.posY, tc.posZ)
}
// The same coordinates must have reached the fusion engine.
p := engineNodePosition(engine, mac)
if p == nil {
t.Fatal("announced position not forwarded to fusion engine")
}
if p.X != tc.posX || p.Y != tc.posY || p.Z != tc.posZ {
t.Errorf("engine position = (%v,%v,%v), want (%v,%v,%v)", p.X, p.Y, p.Z, tc.posX, tc.posY, tc.posZ)
}
})
}
}
// TestManagerOnNodeConnectedWithoutHelloPositionPreservesExisting (bf-24xp)
// verifies the real-ESP32 case: a node that does NOT announce a position (nil
// on all three axes — a real node's position is user-placed in the dashboard)
// leaves any existing registry position untouched, so a connect never clobbers
// a user-placed position with the schema default. A partial announcement
// (only some axes present) is likewise ignored.
func TestManagerOnNodeConnectedWithoutHelloPositionPreservesExisting(t *testing.T) {
const mac = "AA:BB:CC:DD:EE:FF"
reg := newTestRegistry(t)
reg.UpsertNode(mac, "1.0.0", "ESP32-S3")
reg.SetNodePosition(mac, 4.0, 5.0, 6.0) // user-placed while offline
engine := fusion.NewEngine(nil)
mgr := NewManager(reg)
mgr.SetNodePositionSink(func(m string, x, y, z float64) {
engine.SetNodePosition(m, x, y, z)
})
// Connect with no announced position; the existing (4,5,6) must survive.
mgr.OnNodeConnected(mac, "1.0.0", "ESP32-S3", nil, nil, nil)
rx, ry, rz, ok := reg.GetNodePosition(mac)
if !ok {
t.Fatal("node row missing after connect")
}
if rx != 4.0 || ry != 5.0 || rz != 6.0 {
t.Errorf("existing position overwritten by nil announce; got (%v,%v,%v), want (4,5,6)", rx, ry, rz)
}
// A partial announcement (only X present) must also be ignored.
onlyX := 9.0
reg.SetNodePosition(mac, 4.0, 5.0, 6.0) // reset
mgr.OnNodeConnected(mac, "1.0.0", "ESP32-S3", &onlyX, nil, nil)
rx, ry, rz, ok = reg.GetNodePosition(mac)
if !ok {
t.Fatal("node row missing after partial-announce connect")
}
if rx != 4.0 || ry != 5.0 || rz != 6.0 {
t.Errorf("existing position overwritten by partial announce; got (%v,%v,%v), want (4,5,6)", rx, ry, rz)
}
}
// ─── Fleet page specific tests ─────────────────────────────────────────────────────
// TestFleetTableRendering verifies that the fleet table renders correctly with 4 nodes
// and all columns are populated as required by the task specification.
func TestFleetTableRendering(t *testing.T) {
reg := newTestRegistry(t)
// Create 4 test nodes with different configurations
testNodes := []struct {
mac string
name string
role string
firmware string
chip string
x, y, z float64
health float64
online bool
}{
{
mac: "AA:BB:CC:DD:EE:01",
name: "Living Room Node",
role: "tx",
firmware: "1.2.0",
chip: "ESP32-S3",
x: 0.0,
y: 0.0,
z: 2.5,
health: 0.95,
online: true,
},
{
mac: "AA:BB:CC:DD:EE:02",
name: "Kitchen Node",
role: "rx",
firmware: "1.1.5",
chip: "ESP32-S3",
x: 5.0,
y: 3.0,
z: 2.5,
health: 0.88,
online: true,
},
{
mac: "AA:BB:CC:DD:EE:03",
name: "Bedroom Node",
role: "tx_rx",
firmware: "1.2.0",
chip: "ESP32-S3",
x: 4.0,
y: 6.0,
z: 2.5,
health: 0.92,
online: false,
},
{
mac: "AA:BB:CC:DD:EE:04",
name: "Garage Node",
role: "passive",
firmware: "1.0.8",
chip: "ESP32-S3",
x: 8.0,
y: 2.0,
z: 1.5,
health: 0.75,
online: true,
},
}
// Setup connected MACs for online nodes
var connectedMACs []string
for _, node := range testNodes {
reg.UpsertNode(node.mac, node.firmware, node.chip)
reg.SetNodeLabel(node.mac, node.name)
reg.SetNodeRole(node.mac, node.role)
reg.SetNodePosition(node.mac, node.x, node.y, node.z)
if node.online {
connectedMACs = append(connectedMACs, node.mac)
}
}
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return connectedMACs
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
nodes := resp.Nodes
// Verify we have exactly 4 nodes
if len(nodes) != 4 {
t.Fatalf("Expected 4 nodes, got %d", len(nodes))
}
// Verify all columns are populated for each node
for i, node := range nodes {
// Verify MAC address is present
if node.MAC == "" {
t.Errorf("Node %d: MAC address is empty", i)
}
// Verify Label is populated
if node.Label == "" {
t.Errorf("Node %d: Label is empty", i)
}
// Verify Role is valid
validRoles := map[string]bool{"tx": true, "rx": true, "tx_rx": true, "passive": true, "virtual": true}
if !validRoles[node.Role] {
t.Errorf("Node %d: Invalid role '%s'", i, node.Role)
}
// Verify Status is either online or offline
if node.Status != "online" && node.Status != "offline" {
t.Errorf("Node %d: Invalid status '%s', expected 'online' or 'offline'", i, node.Status)
}
// Verify FirmwareVersion is present
if node.FirmwareVersion == "" {
t.Errorf("Node %d: FirmwareVersion is empty", i)
}
// Verify ChipModel is present
if node.ChipModel == "" {
t.Errorf("Node %d: ChipModel is empty", i)
}
// Verify HealthScore is between 0 and 1
if node.HealthScore < 0 || node.HealthScore > 1 {
t.Errorf("Node %d: HealthScore %v is out of range [0,1]", i, node.HealthScore)
}
// Verify LastSeenMS is set
if node.LastSeenMS == 0 {
t.Errorf("Node %d: LastSeenMS is 0", i)
}
// Verify UptimeSeconds is non-negative
if node.UptimeSeconds < 0 {
t.Errorf("Node %d: UptimeSeconds is negative: %d", i, node.UptimeSeconds)
}
// Verify ConfiguredRate is set (should default to 20)
if node.ConfiguredRate != 20 {
t.Errorf("Node %d: ConfiguredRate is %d, expected 20", i, node.ConfiguredRate)
}
}
// Verify specific node properties match what we set
nodeMap := make(map[string]FleetNode)
for _, node := range nodes {
nodeMap[node.MAC] = node
}
// Check first node (Living Room - online)
livingRoom, ok := nodeMap["AA:BB:CC:DD:EE:01"]
if !ok {
t.Fatal("Living Room node not found")
}
if livingRoom.Label != "Living Room Node" {
t.Errorf("Living Room: expected label 'Living Room Node', got '%s'", livingRoom.Label)
}
if livingRoom.Role != "tx" {
t.Errorf("Living Room: expected role 'tx', got '%s'", livingRoom.Role)
}
if livingRoom.Status != "online" {
t.Errorf("Living Room: expected status 'online', got '%s'", livingRoom.Status)
}
if livingRoom.FirmwareVersion != "1.2.0" {
t.Errorf("Living Room: expected firmware '1.2.0', got '%s'", livingRoom.FirmwareVersion)
}
// Check third node (Bedroom - offline)
bedroom, ok := nodeMap["AA:BB:CC:DD:EE:03"]
if !ok {
t.Fatal("Bedroom node not found")
}
if bedroom.Status != "offline" {
t.Errorf("Bedroom: expected status 'offline', got '%s'", bedroom.Status)
}
if bedroom.Role != "tx_rx" {
t.Errorf("Bedroom: expected role 'tx_rx', got '%s'", bedroom.Role)
}
}
// TestFleetNodeFields verifies that all required FleetNode fields are properly
// computed and returned by the API.
func TestFleetNodeFields(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Test Node")
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 1.5, 2.5, 3.0)
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
nodes := resp.Nodes
if len(nodes) != 1 {
t.Fatalf("Expected 1 node, got %d", len(nodes))
}
node := nodes[0]
// Verify all 11 table columns have data:
// 1. Checkbox - represented by MAC for selection
if node.MAC != "AA:BB:CC:DD:EE:FF" {
t.Errorf("MAC: expected 'AA:BB:CC:DD:EE:FF', got '%s'", node.MAC)
}
// 2. Label
if node.Label != "Test Node" {
t.Errorf("Label: expected 'Test Node', got '%s'", node.Label)
}
// 3. MAC address (duplicate in table)
if node.MAC == "" {
t.Error("MAC address is empty")
}
// 4. Status
if node.Status != "online" {
t.Errorf("Status: expected 'online', got '%s'", node.Status)
}
// 5. Firmware version
if node.FirmwareVersion != "1.2.0" {
t.Errorf("FirmwareVersion: expected '1.2.0', got '%s'", node.FirmwareVersion)
}
// 6. Uptime (computed field) - can be 0 for newly created test nodes
if node.UptimeSeconds < 0 {
t.Errorf("UptimeSeconds: expected non-negative value, got %d", node.UptimeSeconds)
}
// 7. Role
if node.Role != "rx" {
t.Errorf("Role: expected 'rx', got '%s'", node.Role)
}
// 8. Signal health (HealthScore)
if node.HealthScore < 0 || node.HealthScore > 1 {
t.Errorf("HealthScore: expected value in [0,1], got %v", node.HealthScore)
}
// 9. Packet rate (computed field)
if node.PacketRate < 0 {
t.Errorf("PacketRate: expected non-negative value, got %v", node.PacketRate)
}
// 10. Temperature (currently returns 0 as placeholder)
if node.Temperature != 0 {
t.Logf("Temperature: got %v (currently returns 0)", node.Temperature)
}
// 11. Actions - represented by MAC for API calls
if node.MAC == "" {
t.Error("MAC is empty, needed for action buttons")
}
}
// TestFleetWithVirtualNodes verifies that virtual nodes are included in the fleet list.
func TestFleetWithVirtualNodes(t *testing.T) {
reg := newTestRegistry(t)
// Add a real node
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Real Node")
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
// Add a virtual node
reg.AddVirtualNode("11:22:33:44:55:66", "Virtual Node", 2.0, 3.0, 4.0)
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
nodes := resp.Nodes
if len(nodes) != 2 {
t.Fatalf("Expected 2 nodes, got %d", len(nodes))
}
// Verify virtual node is marked correctly
var virtualNode *FleetNode
for i := range nodes {
if nodes[i].MAC == "11:22:33:44:55:66" {
virtualNode = &nodes[i]
break
}
}
if virtualNode == nil {
t.Fatal("Virtual node not found in fleet list")
}
if !virtualNode.Virtual {
t.Error("Virtual node should have Virtual field set to true")
}
if virtualNode.Label != "Virtual Node" {
t.Errorf("Virtual node label: expected 'Virtual Node', got '%s'", virtualNode.Label)
}
if virtualNode.Role != "virtual" {
t.Errorf("Virtual node role: expected 'virtual', got '%s'", virtualNode.Role)
}
if virtualNode.Status != "offline" {
// Virtual nodes are not connected, so they should be offline
t.Errorf("Virtual node status: expected 'offline', got '%s'", virtualNode.Status)
}
}
// TestFleetWithNoNodes verifies the API returns an empty array when no nodes exist.
func TestFleetWithNoNodes(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if len(resp.Nodes) != 0 {
t.Errorf("Expected 0 nodes, got %d", len(resp.Nodes))
}
}
// TestFleetNodeStatusOffline verifies that offline nodes are correctly identified.
func TestFleetNodeStatusOffline(t *testing.T) {
reg := newTestRegistry(t)
// Add a node but don't include it in connected list
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Offline Node")
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{} // No connected nodes
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
nodes := resp.Nodes
if len(nodes) != 1 {
t.Fatalf("Expected 1 node, got %d", len(nodes))
}
if nodes[0].Status != "offline" {
t.Errorf("Expected status 'offline', got '%s'", nodes[0].Status)
}
}
// TestFleetWithUnpairedNode verifies that unpaired nodes are correctly flagged
// and show "unpaired" status in the fleet list.
func TestFleetWithUnpairedNode(t *testing.T) {
reg := newTestRegistry(t)
// Add a regular online node
reg.UpsertNode("AA:BB:CC:DD:EE:01", "1.2.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:01", "Paired Node")
reg.SetNodeRole("AA:BB:CC:DD:EE:01", "rx")
// Add an unpaired node (connected without valid token)
reg.UpsertNode("AA:BB:CC:DD:EE:02", "1.2.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:02", "Unpaired Node")
reg.SetNodeRole("AA:BB:CC:DD:EE:02", "rx")
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"}
},
getUnpairedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:02"}
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if len(resp.Nodes) != 2 {
t.Fatalf("Expected 2 nodes, got %d", len(resp.Nodes))
}
nodeMap := make(map[string]FleetNode)
for _, n := range resp.Nodes {
nodeMap[n.MAC] = n
}
// Paired node should be online
paired, ok := nodeMap["AA:BB:CC:DD:EE:01"]
if !ok {
t.Fatal("Paired node not found")
}
if paired.Status != "online" {
t.Errorf("Paired node: expected status 'online', got '%s'", paired.Status)
}
if paired.Unpaired {
t.Error("Paired node should not be marked as unpaired")
}
// Unpaired node should have unpaired status and flag
unpaired, ok := nodeMap["AA:BB:CC:DD:EE:02"]
if !ok {
t.Fatal("Unpaired node not found")
}
if unpaired.Status != "unpaired" {
t.Errorf("Unpaired node: expected status 'unpaired', got '%s'", unpaired.Status)
}
if !unpaired.Unpaired {
t.Error("Unpaired node should have Unpaired=true")
}
}
// TestFleetAllUnpaired verifies that when all connected nodes are unpaired,
// the fleet list still returns them with the correct status.
func TestFleetAllUnpaired(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Unpaired Only")
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
getUnpairedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
nodes := resp.Nodes
if len(nodes) != 1 {
t.Fatalf("Expected 1 node, got %d", len(nodes))
}
if nodes[0].Status != "unpaired" {
t.Errorf("Expected status 'unpaired', got '%s'", nodes[0].Status)
}
if !nodes[0].Unpaired {
t.Error("Expected Unpaired=true")
}
}
// TestFleetListMigrationWindowActive verifies that migration window metadata
// is returned when the deadline is in the future.
func TestFleetListMigrationWindowActive(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
mgr := NewManager(reg)
deadline := time.Now().Add(12 * time.Hour)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
},
migProvider: &mockMigProvider{deadline: deadline},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListFullResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if !resp.MigrationWindowActive {
t.Error("Expected migration_window_active to be true")
}
if resp.MigrationDeadlineMS == 0 {
t.Error("Expected migration_deadline_ms to be set")
}
if resp.MigrationRemainingSecs <= 0 {
t.Errorf("Expected migration_remaining_secs > 0, got %v", resp.MigrationRemainingSecs)
}
}
// TestFleetListMigrationWindowClosed verifies that the migration window is
// reported as inactive when the deadline has passed.
func TestFleetListMigrationWindowClosed(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
mgr := NewManager(reg)
// Deadline in the past
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
},
migProvider: &mockMigProvider{deadline: time.Now().Add(-1 * time.Hour)},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListFullResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if resp.MigrationWindowActive {
t.Error("Expected migration_window_active to be false when deadline has passed")
}
}
// TestFleetListNoMigrationWindow verifies that migration window fields are
// absent when no migration deadline provider is configured.
func TestFleetListNoMigrationWindow(t *testing.T) {
reg := newTestRegistry(t)
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:FF"}
},
},
// No migProvider set
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListFullResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if resp.MigrationWindowActive {
t.Error("Expected migration_window_active to be false with no provider")
}
if resp.MigrationDeadlineMS != 0 {
t.Error("Expected migration_deadline_ms to be 0 with no provider")
}
}
// TestFleetListUnpairedNotInRegistry verifies that an unpaired node whose
// MAC is not in the registry is still appended to the fleet list.
func TestFleetListUnpairedNotInRegistry(t *testing.T) {
reg := newTestRegistry(t)
// Only one registered node
reg.UpsertNode("AA:BB:CC:DD:EE:01", "1.0.0", "ESP32-S3")
reg.SetNodeLabel("AA:BB:CC:DD:EE:01", "Registered Node")
mgr := NewManager(reg)
h := &Handler{
mgr: mgr,
nodeID: &mockNodeIdentifier{
getConnectedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"}
},
getUnpairedMACs: func() []string {
return []string{"AA:BB:CC:DD:EE:02"} // Not in registry
},
},
}
req := httptest.NewRequest("GET", "/api/fleet", nil)
w := httptest.NewRecorder()
h.listFleet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
}
var resp fleetListResp
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
t.Fatalf("Failed to decode response: %v", err)
}
if len(resp.Nodes) != 2 {
t.Fatalf("Expected 2 nodes (1 registered + 1 unregistered unpaired), got %d", len(resp.Nodes))
}
nodeMap := make(map[string]FleetNode)
for _, n := range resp.Nodes {
nodeMap[n.MAC] = n
}
// Registered node should be online
regNode, ok := nodeMap["AA:BB:CC:DD:EE:01"]
if !ok {
t.Fatal("Registered node not found")
}
if regNode.Status != "online" {
t.Errorf("Registered node: expected status 'online', got '%s'", regNode.Status)
}
if regNode.Unpaired {
t.Error("Registered node should not be unpaired")
}
// Unregistered unpaired node should be present with unpaired status
unregNode, ok := nodeMap["AA:BB:CC:DD:EE:02"]
if !ok {
t.Fatal("Unregistered unpaired node not found in fleet list")
}
if unregNode.Status != "unpaired" {
t.Errorf("Unregistered unpaired node: expected status 'unpaired', got '%s'", unregNode.Status)
}
if !unregNode.Unpaired {
t.Error("Unregistered unpaired node should have Unpaired=true")
}
if unregNode.Role != "rx" {
t.Errorf("Unregistered unpaired node: expected default role 'rx', got '%s'", unregNode.Role)
}
}
// ─── Disable node endpoint tests ───────────────────────────────────────────────────
func TestHandlerDisableNode(t *testing.T) {
tests := []struct {
name string
mac string
initialRole string
nodeExists bool
wantStatus int
expectedRole string
expectedPrior string
}{
{
name: "successful disable from tx",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "tx",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "idle",
expectedPrior: "tx",
},
{
name: "successful disable from rx",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "rx",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "idle",
expectedPrior: "rx",
},
{
name: "successful disable from tx_rx",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "tx_rx",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "idle",
expectedPrior: "tx_rx",
},
{
name: "node already idle returns success",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "idle",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "idle",
expectedPrior: "",
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "tx",
nodeExists: false,
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Test Node")
reg.SetNodeRole(tt.mac, tt.initialRole)
}
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/disable", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.disableNode(w, req)
if w.Code != tt.wantStatus {
t.Errorf("disableNode() status = %v, want %v", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK && tt.nodeExists && tt.initialRole != "idle" {
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp["ok"] != true {
t.Errorf("Expected ok to be true, got %v", resp["ok"])
}
if resp["current_role"] != tt.expectedRole {
t.Errorf("Expected current_role to be %s, got %v", tt.expectedRole, resp["current_role"])
}
if resp["prior_role"] != tt.expectedPrior {
t.Errorf("Expected prior_role to be %s, got %v", tt.expectedPrior, resp["prior_role"])
}
// Verify the role was saved to role_before_disable
savedRole, err := reg.GetNodeRoleBeforeDisable(tt.mac)
if err != nil {
t.Errorf("Failed to get role_before_disable: %v", err)
}
if savedRole != tt.expectedPrior {
t.Errorf("Expected role_before_disable to be %s, got %s", tt.expectedPrior, savedRole)
}
}
})
}
}
// ─── Enable node endpoint tests ───────────────────────────────────────────────────
func TestHandlerEnableNode(t *testing.T) {
tests := []struct {
name string
mac string
initialRole string
savedPriorRole string
nodeExists bool
wantStatus int
expectedRole string
expectedNote string
}{
{
name: "successful enable from idle with saved prior role",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "idle",
savedPriorRole: "tx",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "tx",
},
{
name: "successful enable from idle with rx saved",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "idle",
savedPriorRole: "rx",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "rx",
},
{
name: "successful enable from idle with tx_rx saved",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "idle",
savedPriorRole: "tx_rx",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "tx_rx",
},
{
name: "enable idle node with no saved role defaults to rx",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "idle",
savedPriorRole: "",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "rx",
},
{
name: "node already enabled returns current state",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "tx",
savedPriorRole: "",
nodeExists: true,
wantStatus: http.StatusOK,
expectedRole: "tx",
expectedNote: "node already enabled",
},
{
name: "node not found",
mac: "AA:BB:CC:DD:EE:FF",
initialRole: "idle",
savedPriorRole: "tx",
nodeExists: false,
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry(t)
if tt.nodeExists {
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(tt.mac, "Test Node")
reg.SetNodeRole(tt.mac, tt.initialRole)
if tt.savedPriorRole != "" {
reg.SetNodeRoleBeforeDisable(tt.mac, tt.savedPriorRole)
}
}
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/enable", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", tt.mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.enableNode(w, req)
if w.Code != tt.wantStatus {
t.Errorf("enableNode() status = %v, want %v", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK && tt.nodeExists {
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp["ok"] != true {
t.Errorf("Expected ok to be true, got %v", resp["ok"])
}
// When node is already enabled, response contains current_role instead of restored_role
roleKey := "restored_role"
if tt.expectedNote != "" {
roleKey = "current_role"
if resp["note"] != tt.expectedNote {
t.Errorf("Expected note to be %s, got %v", tt.expectedNote, resp["note"])
}
}
if resp[roleKey] != tt.expectedRole {
t.Errorf("Expected %s to be %s, got %v", roleKey, tt.expectedRole, resp[roleKey])
}
// Verify the node's current role was updated
node, err := reg.GetNode(tt.mac)
if err != nil {
t.Errorf("Failed to get node after enable: %v", err)
} else if node.Role != tt.expectedRole {
t.Errorf("Expected node role to be %s, got %s", tt.expectedRole, node.Role)
}
}
})
}
}
// TestHandlerDisableEnableRoundTrip tests the full disable/enable cycle.
func TestHandlerDisableEnableRoundTrip(t *testing.T) {
reg := newTestRegistry(t)
mac := "AA:BB:CC:DD:EE:FF"
// Setup node with initial role
reg.UpsertNode(mac, "1.0.0", "ESP32-S3")
reg.SetNodeLabel(mac, "Test Node")
reg.SetNodeRole(mac, "tx")
mgr := NewManager(reg)
h := &Handler{mgr: mgr}
// Disable the node
req := httptest.NewRequest("POST", "/api/nodes/"+mac+"/disable", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("mac", mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.disableNode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("disableNode() status = %v, want %v", w.Code, http.StatusOK)
}
// Verify role is now idle
node, err := reg.GetNode(mac)
if err != nil {
t.Fatalf("Failed to get node after disable: %v", err)
}
if node.Role != "idle" {
t.Errorf("Expected role to be idle after disable, got %s", node.Role)
}
// Verify prior role was saved
savedRole, err := reg.GetNodeRoleBeforeDisable(mac)
if err != nil {
t.Fatalf("Failed to get role_before_disable: %v", err)
}
if savedRole != "tx" {
t.Errorf("Expected role_before_disable to be tx, got %s", savedRole)
}
// Enable the node
req = httptest.NewRequest("POST", "/api/nodes/"+mac+"/enable", nil)
rctx = chi.NewRouteContext()
rctx.URLParams.Add("mac", mac)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w = httptest.NewRecorder()
h.enableNode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("enableNode() status = %v, want %v", w.Code, http.StatusOK)
}
// Verify role was restored
node, err = reg.GetNode(mac)
if err != nil {
t.Fatalf("Failed to get node after enable: %v", err)
}
if node.Role != "tx" {
t.Errorf("Expected role to be tx after enable, got %s", node.Role)
}
// Verify role_before_disable was cleared
savedRole, err = reg.GetNodeRoleBeforeDisable(mac)
if err != nil {
t.Fatalf("Failed to get role_before_disable after enable: %v", err)
}
if savedRole != "" {
t.Errorf("Expected role_before_disable to be cleared after enable, got %s", savedRole)
}
}
// TestRouteRegistrationNoPanic verifies that registering both Handler and FleetHandler
// on the same router does not cause chi to panic with duplicate route registration.
// This is a startup smoke test to catch accidental route conflicts early.
func TestRouteRegistrationNoPanic(t *testing.T) {
reg := newTestRegistry(t)
mgr := NewManager(reg)
selfHealMgr := NewSelfHealManager(reg, NewRoleOptimiser(DefaultOptimisationConfig()), DefaultSelfHealConfig())
// Create both handlers as main.go does
h := NewHandler(mgr)
fleetHealthHandler := NewFleetHandler(selfHealMgr, reg)
// This should not panic - if there are duplicate routes, chi will panic here
r := chi.NewRouter()
h.RegisterRoutes(r)
fleetHealthHandler.RegisterRoutes(r)
// Verify expected routes are registered
// From Handler: /api/nodes, /api/fleet (list), /api/nodes/{mac}/role, etc.
// From FleetHandler: /api/fleet/health, /api/fleet/history, /api/fleet/optimise, /api/fleet/simulate
// The key is no panic on duplicate routes like POST /api/nodes/{mac}/role
// Walk the routes to ensure they're registered
expectedRoutes := make(map[string]bool)
expectedRoutes["/api/nodes"] = false
expectedRoutes["/api/fleet"] = false
expectedRoutes["/api/fleet/health"] = false
expectedRoutes["/api/fleet/history"] = false
expectedRoutes["/api/fleet/optimise"] = false
expectedRoutes["/api/fleet/simulate"] = false
// Use chi.Walk to iterate over all registered routes
err := chi.Walk(r, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
for pattern := range expectedRoutes {
if route == pattern {
expectedRoutes[pattern] = true
}
}
return nil
})
if err != nil {
t.Fatalf("Failed to walk routes: %v", err)
}
// Check that all expected routes were found
for pattern, found := range expectedRoutes {
if !found {
t.Errorf("Expected route %s not found", pattern)
}
}
}