spaxel/mothership/internal/fleet/handler_test.go
jedarden 15c082c344 fleet: add idle role to validRoles and add disable/enable tests
- Added "idle" to validRoles map for consistency with disable/enable flow
- Added comprehensive tests for disableNode and enableNode handlers
- Tests cover: disable from tx/rx/tx_rx, already idle, node not found
- Tests cover: enable with saved role, no saved role (defaults to rx),
  already enabled, node not found
- Added round-trip test for full disable/enable cycle

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 14:10:52 -04:00

2512 lines
66 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)
}
}