spaxel/mothership/internal/fleet/handler_test.go
jedarden 5d78e08441 fix: correct test expected responses to include newline
The writeJSON function uses json.NewEncoder which adds a newline
character. Changed raw string literals to interpreted strings
so \n becomes an actual newline character.
2026-04-11 10:34:31 -04:00

1833 lines
47 KiB
Go

package fleet
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"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
}
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{}
}
// 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 {
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 {
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 nodes []FleetNode
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
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 nodes []FleetNode
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(nodes) != 0 {
t.Errorf("Expected 0 nodes, got %d", len(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 {
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 {
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 {
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 {
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 nodes []FleetNode
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
// 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 nodes []FleetNode
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
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 nodes []FleetNode
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
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 nodes []FleetNode
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(nodes) != 0 {
t.Errorf("Expected 0 nodes, got %d", len(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 nodes []FleetNode
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
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)
}
}