spaxel/mothership/internal/fleet/handler_test.go
jedarden 2c8bbcf646
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
fix(fleet): remove duplicate route registration to prevent chi panic
- Remove duplicate node-specific routes (role, label, locate, delete) from
  FleetHandler.RegisterRoutes to avoid chi panic on duplicate registration
- Keep only unique FleetHandler routes: /api/fleet/health, /api/fleet/history,
  /api/fleet/optimise, /api/fleet/simulate
- Add startup smoke test TestRouteRegistrationNoPanic to verify both Handler
  and FleetHandler can be registered on same router without panic

main.go registers both fleet.NewHandler and fleet.NewFleetHandler on the
same router, which previously caused chi to panic due to duplicate routes:
  POST /api/nodes/{mac}/role
  PATCH /api/nodes/{mac}/label
  POST /api/nodes/{mac}/locate
  DELETE /api/nodes/{mac}

The Handler has comprehensive node/room/mode endpoints while FleetHandler
focuses on health/optimization/simulation, so duplicates are removed from
FleetHandler.

Closes: bf-3o15x
2026-05-24 09:47:54 -04:00

2564 lines
68 KiB
Go

package fleet
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
// 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)
}
}
})
}
}
// ─── 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
// Try to walk the routes to ensure they're registered
routes := chi.Routes(r)
if len(routes) == 0 {
t.Fatal("No routes registered")
}
// Verify some expected routes exist
expectedRoutes := []string{
"/api/nodes",
"/api/fleet",
"/api/fleet/health",
"/api/fleet/history",
"/api/fleet/optimise",
"/api/fleet/simulate",
}
for _, expected := range expectedRoutes {
found := false
for _, route := range routes {
if route.Pattern == expected {
found = true
break
}
}
if !found {
t.Errorf("Expected route %s not found", expected)
}
}
}