Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
- Remove duplicate node-specific routes (role, label, locate, delete) from
FleetHandler.RegisterRoutes to avoid chi panic on duplicate registration
- Keep only unique FleetHandler routes: /api/fleet/health, /api/fleet/history,
/api/fleet/optimise, /api/fleet/simulate
- Add startup smoke test TestRouteRegistrationNoPanic to verify both Handler
and FleetHandler can be registered on same router without panic
main.go registers both fleet.NewHandler and fleet.NewFleetHandler on the
same router, which previously caused chi to panic due to duplicate routes:
POST /api/nodes/{mac}/role
PATCH /api/nodes/{mac}/label
POST /api/nodes/{mac}/locate
DELETE /api/nodes/{mac}
The Handler has comprehensive node/room/mode endpoints while FleetHandler
focuses on health/optimization/simulation, so duplicates are removed from
FleetHandler.
Closes: bf-3o15x
2564 lines
68 KiB
Go
2564 lines
68 KiB
Go
package fleet
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// mockNodeIdentifier is a mock implementation of NodeIdentifier for testing.
|
|
type mockNodeIdentifier struct {
|
|
sendIdentifyFunc func(mac string, durationMS int) bool
|
|
sendRebootFunc func(mac string, delayMS int) bool
|
|
getConnectedMACs func() []string
|
|
getUnpairedMACs func() []string
|
|
}
|
|
|
|
func (m *mockNodeIdentifier) SendIdentifyToMAC(mac string, durationMS int) bool {
|
|
if m.sendIdentifyFunc != nil {
|
|
return m.sendIdentifyFunc(mac, durationMS)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *mockNodeIdentifier) SendRebootToMAC(mac string, delayMS int) bool {
|
|
if m.sendRebootFunc != nil {
|
|
return m.sendRebootFunc(mac, delayMS)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *mockNodeIdentifier) GetConnectedMACs() []string {
|
|
if m.getConnectedMACs != nil {
|
|
return m.getConnectedMACs()
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
func (m *mockNodeIdentifier) GetUnpairedMACs() []string {
|
|
if m.getUnpairedMACs != nil {
|
|
return m.getUnpairedMACs()
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
// mockMigProvider is a mock MigrationDeadlineProvider for testing.
|
|
type mockMigProvider struct {
|
|
deadline time.Time
|
|
}
|
|
|
|
func (m *mockMigProvider) GetMigrationDeadline() time.Time {
|
|
return m.deadline
|
|
}
|
|
|
|
// fleetListResp mirrors fleetListResponse for test decoding.
|
|
type fleetListResp struct {
|
|
Nodes []FleetNode `json:"nodes"`
|
|
}
|
|
|
|
// fleetListFullResp mirrors fleetListResponse with migration window fields.
|
|
type fleetListFullResp struct {
|
|
Nodes []FleetNode `json:"nodes"`
|
|
MigrationWindowActive bool `json:"migration_window_active"`
|
|
MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"`
|
|
MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"`
|
|
}
|
|
|
|
// mockRegistry is a mock implementation of Registry for testing.
|
|
type mockRegistry struct {
|
|
nodes map[string]NodeRecord
|
|
}
|
|
|
|
func (m *mockRegistry) GetNode(mac string) (*NodeRecord, error) {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
return &node, nil
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) GetAllNodes() ([]NodeRecord, error) {
|
|
var nodes []NodeRecord
|
|
for _, node := range m.nodes {
|
|
nodes = append(nodes, node)
|
|
}
|
|
return nodes, nil
|
|
}
|
|
|
|
func (m *mockRegistry) SetNodeLabel(mac, label string) error {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
node.Name = label
|
|
m.nodes[mac] = node
|
|
return nil
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) SetNodePosition(mac string, x, y, z float64) error {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
node.PosX = x
|
|
node.PosY = y
|
|
node.PosZ = z
|
|
m.nodes[mac] = node
|
|
return nil
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) AddVirtualNode(mac, name string, x, y, z float64) error {
|
|
m.nodes[mac] = NodeRecord{
|
|
MAC: mac,
|
|
Name: name,
|
|
Role: "virtual",
|
|
PosX: x,
|
|
PosY: y,
|
|
PosZ: z,
|
|
Virtual: true,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRegistry) DeleteNode(mac string) error {
|
|
delete(m.nodes, mac)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRegistry) UpsertNode(mac, firmware, chip string) error {
|
|
if _, ok := m.nodes[mac]; !ok {
|
|
m.nodes[mac] = NodeRecord{
|
|
MAC: mac,
|
|
Name: "",
|
|
Role: "rx",
|
|
FirmwareVersion: firmware,
|
|
ChipModel: chip,
|
|
}
|
|
} else {
|
|
node := m.nodes[mac]
|
|
node.FirmwareVersion = firmware
|
|
node.ChipModel = chip
|
|
m.nodes[mac] = node
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRegistry) SetNodeRole(mac, role string) error {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
node.Role = role
|
|
m.nodes[mac] = node
|
|
return nil
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func TestHandlerIdentifyNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
reqBody string
|
|
nodeExists bool
|
|
nodeConnected bool
|
|
wantStatus int
|
|
wantResponse string
|
|
}{
|
|
{
|
|
name: "successful identify with default duration",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
{
|
|
name: "successful identify with custom duration",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{"duration_ms": 10000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{}`,
|
|
nodeExists: false,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusNotFound,
|
|
wantResponse: "node not found\n",
|
|
},
|
|
{
|
|
name: "node not connected",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: false,
|
|
wantStatus: http.StatusNotFound,
|
|
wantResponse: "node not connected\n",
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `invalid json`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantResponse: "invalid request body\n",
|
|
},
|
|
{
|
|
name: "zero duration uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{"duration_ms": 0}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
{
|
|
name: "negative duration uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{"duration_ms": -1000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create a real registry with the test node
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
err := reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
if err != nil {
|
|
t.Fatalf("UpsertNode: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create a manager with the registry
|
|
mgr := NewManager(reg)
|
|
|
|
// Create handler with mock node identifier
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendIdentifyFunc: func(mac string, durationMS int) bool {
|
|
return tt.nodeConnected
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a test request
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/identify", bytes.NewBufferString(tt.reqBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Use chi URLParam to set the MAC parameter
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
// Create response recorder
|
|
w := httptest.NewRecorder()
|
|
|
|
// Call the handler
|
|
h.identifyNode(w, req)
|
|
|
|
// Check status code
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("identifyNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
// Check response body
|
|
if tt.wantResponse != "" {
|
|
resp := w.Body.String()
|
|
if resp != tt.wantResponse {
|
|
t.Errorf("identifyNode() response = %q, want %q", resp, tt.wantResponse)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerIdentifyNodeDurationParsing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reqBody string
|
|
expectedDuration int
|
|
}{
|
|
{
|
|
name: "default duration when not specified",
|
|
reqBody: `{}`,
|
|
expectedDuration: 5000,
|
|
},
|
|
{
|
|
name: "custom duration",
|
|
reqBody: `{"duration_ms": 10000}`,
|
|
expectedDuration: 10000,
|
|
},
|
|
{
|
|
name: "zero uses default",
|
|
reqBody: `{"duration_ms": 0}`,
|
|
expectedDuration: 5000,
|
|
},
|
|
{
|
|
name: "negative uses default",
|
|
reqBody: `{"duration_ms": -1000}`,
|
|
expectedDuration: 5000,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var actualDuration int
|
|
|
|
reg := newTestRegistry(t)
|
|
err := reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
if err != nil {
|
|
t.Fatalf("UpsertNode: %v", err)
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendIdentifyFunc: func(mac string, durationMS int) bool {
|
|
actualDuration = durationMS
|
|
return true
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/AA:BB:CC:DD:EE:FF/identify", bytes.NewBufferString(tt.reqBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", "AA:BB:CC:DD:EE:FF")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.identifyNode(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status OK, got %v", w.Code)
|
|
}
|
|
|
|
if actualDuration != tt.expectedDuration {
|
|
t.Errorf("Duration = %v, want %v", actualDuration, tt.expectedDuration)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIdentifyNodeRequest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid empty object",
|
|
json: `{}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid with duration",
|
|
json: `{"duration_ms": 10000}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid with zero duration",
|
|
json: `{"duration_ms": 0}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid with negative duration",
|
|
json: `{"duration_ms": -1000}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
json: `invalid`,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "extra fields ignored",
|
|
json: `{"duration_ms": 5000, "extra": "ignored"}`,
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var req identifyNodeRequest
|
|
err := json.NewDecoder(bytes.NewBufferString(tt.json)).Decode(&req)
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("json.NewDecoder().Decode() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── System mode endpoint tests ─────────────────────────────────────────────
|
|
|
|
func TestHandlerGetSystemMode(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
mgr := NewManager(reg)
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/mode", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.getSystemMode(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("getSystemMode() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp systemModeResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.Mode != "home" {
|
|
t.Errorf("Expected mode to be home, got %s", resp.Mode)
|
|
}
|
|
|
|
if !resp.AutoAwayConfig.Enabled {
|
|
t.Errorf("Expected auto-away to be enabled by default")
|
|
}
|
|
|
|
if resp.AutoAwayConfig.AbsenceDurationSec != 900 { // 15 minutes
|
|
t.Errorf("Expected absence duration to be 900s, got %d", resp.AutoAwayConfig.AbsenceDurationSec)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSetSystemMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
wantStatus int
|
|
expectedMode string
|
|
}{
|
|
{
|
|
name: "set to away mode",
|
|
requestBody: `{"mode": "away", "reason": "manual"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "away",
|
|
},
|
|
{
|
|
name: "set to home mode",
|
|
requestBody: `{"mode": "home", "reason": "manual"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "home",
|
|
},
|
|
{
|
|
name: "set to sleep mode",
|
|
requestBody: `{"mode": "sleep", "reason": "night"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "sleep",
|
|
},
|
|
{
|
|
name: "mode defaults reason to manual",
|
|
requestBody: `{"mode": "away"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "away",
|
|
},
|
|
{
|
|
name: "invalid mode",
|
|
requestBody: `{"mode": "invalid"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/mode", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
h.setSystemMode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("setSystemMode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusOK {
|
|
var resp systemModeResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.Mode != tt.expectedMode {
|
|
t.Errorf("Expected mode to be %s, got %s", tt.expectedMode, resp.Mode)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Fleet list endpoint tests ────────────────────────────────────────────────
|
|
|
|
func TestHandlerListFleet(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
|
|
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 1.0, 2.0, 3.0)
|
|
reg.UpsertNode("11:22:33:44:55:66", "1.1.0", "ESP32-S3")
|
|
reg.SetNodeLabel("11:22:33:44:55:66", "Node 2")
|
|
reg.SetNodePosition("11:22:33:44:55:66", 4.0, 5.0, 6.0)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendIdentifyFunc: func(mac string, durationMS int) bool {
|
|
return true
|
|
},
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
nodes := resp.Nodes
|
|
|
|
if len(nodes) != 2 {
|
|
t.Errorf("Expected 2 nodes, got %d", len(nodes))
|
|
}
|
|
|
|
// Check first node (should be online)
|
|
if nodes[0].MAC != "AA:BB:CC:DD:EE:FF" {
|
|
t.Errorf("Expected first node MAC to be AA:BB:CC:DD:EE:FF, got %s", nodes[0].MAC)
|
|
}
|
|
if nodes[0].Status != "online" {
|
|
t.Errorf("Expected first node status to be online, got %s", nodes[0].Status)
|
|
}
|
|
|
|
// Check second node (should be offline - not in connected list)
|
|
if nodes[1].MAC != "11:22:33:44:55:66" {
|
|
t.Errorf("Expected second node MAC to be 11:22:33:44:55:66, got %s", nodes[1].MAC)
|
|
}
|
|
if nodes[1].Status != "offline" {
|
|
t.Errorf("Expected second node status to be offline, got %s", nodes[1].Status)
|
|
}
|
|
}
|
|
|
|
func TestHandlerListFleetEmpty(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(resp.Nodes) != 0 {
|
|
t.Errorf("Expected 0 nodes, got %d", len(resp.Nodes))
|
|
}
|
|
}
|
|
|
|
// ─── Get node endpoint tests ───────────────────────────────────────────────────
|
|
|
|
func TestHandlerGetNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
nodeExists bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "node found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/nodes/"+tt.mac, nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.getNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("getNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusOK {
|
|
var node NodeRecord
|
|
if err := json.NewDecoder(w.Body).Decode(&node); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
if node.MAC != tt.mac {
|
|
t.Errorf("Expected MAC to be %s, got %s", tt.mac, node.MAC)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Update node label endpoint tests ───────────────────────────────────────────
|
|
|
|
func TestHandlerUpdateNodeLabel(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
wantStatus int
|
|
expectedLabel string
|
|
}{
|
|
{
|
|
name: "successful label update",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"label": "New Label"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedLabel: "New Label",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"label": "New Label"}`,
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Old Label")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("PATCH", "/api/nodes/"+tt.mac+"/label", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.updateNodeLabel(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("updateNodeLabel() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusNoContent {
|
|
// Verify the label was updated
|
|
node, err := reg.GetNode(tt.mac)
|
|
if err != nil {
|
|
t.Errorf("Failed to get node: %v", err)
|
|
} else if node.Name != tt.expectedLabel {
|
|
t.Errorf("Expected label to be %s, got %s", tt.expectedLabel, node.Name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Set node role endpoint tests ───────────────────────────────────────────────
|
|
|
|
func TestHandlerSetNodeRole(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
wantStatus int
|
|
expectedRole string
|
|
}{
|
|
{
|
|
name: "successful role change to tx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "tx"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "tx",
|
|
},
|
|
{
|
|
name: "successful role change to rx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "rx"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "rx",
|
|
},
|
|
{
|
|
name: "successful role change to tx_rx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "tx_rx"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "tx_rx",
|
|
},
|
|
{
|
|
name: "successful role change to passive",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "passive"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "passive",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "tx"}`,
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid role",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "invalid"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "empty role",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": ""}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/role", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.setNodeRole(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("setNodeRole() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Delete node endpoint tests ─────────────────────────────────────────────────
|
|
|
|
func TestHandlerDeleteNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
nodeExists bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful deletion",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusNoContent,
|
|
},
|
|
{
|
|
name: "delete non-existent node succeeds",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNoContent,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("DELETE", "/api/nodes/"+tt.mac, nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.deleteNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("deleteNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── OTA endpoint tests ───────────────────────────────────────────────────────────
|
|
|
|
func TestHandlerTriggerNodeOTA(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
otaAvailable bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful OTA trigger",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "OTA with specific version",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"version": "1.2.0"}`,
|
|
nodeExists: true,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: false,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "OTA manager not available",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
otaAvailable: false,
|
|
wantStatus: http.StatusOK, // Still succeeds without OTA manager
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
if tt.otaAvailable {
|
|
// OTA manager is optional in the handler
|
|
h.otaMgr = nil // Mock would go here
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/ota", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.triggerNodeOTA(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("triggerNodeOTA() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Reboot endpoint tests ─────────────────────────────────────────────────────────
|
|
|
|
func TestHandlerRebootNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
nodeConnected bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful reboot with default delay",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "successful reboot with custom delay",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"delay_ms": 5000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: false,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "node not connected",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "zero delay uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"delay_ms": 0}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "negative delay uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"delay_ms": -1000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendRebootFunc: func(mac string, delayMS int) bool {
|
|
return tt.nodeConnected
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/reboot", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.rebootNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("rebootNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Update all endpoint tests ────────────────────────────────────────────────────
|
|
|
|
func TestHandlerUpdateAllNodes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
connectedMACs []string
|
|
wantStatus int
|
|
expectedCount int
|
|
}{
|
|
{
|
|
name: "update all connected nodes",
|
|
connectedMACs: []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"},
|
|
wantStatus: http.StatusOK,
|
|
expectedCount: 2,
|
|
},
|
|
{
|
|
name: "no connected nodes",
|
|
connectedMACs: []string{},
|
|
wantStatus: http.StatusOK,
|
|
expectedCount: 0,
|
|
},
|
|
{
|
|
name: "single connected node",
|
|
connectedMACs: []string{"AA:BB:CC:DD:EE:FF"},
|
|
wantStatus: http.StatusOK,
|
|
expectedCount: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return tt.connectedMACs
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/update-all", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.updateAllNodes(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("updateAllNodes() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
count, ok := resp["count"].(float64)
|
|
if !ok {
|
|
t.Fatalf("Expected count to be a number, got %T", resp["count"])
|
|
}
|
|
|
|
if int(count) != tt.expectedCount {
|
|
t.Errorf("Expected count to be %d, got %d", tt.expectedCount, int(count))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Export/Import endpoint tests ─────────────────────────────────────────────────
|
|
|
|
func TestHandlerExportConfig(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
reg.UpsertNode("11:22:33:44:55:66", "1.1.0", "ESP32-S3")
|
|
reg.SetNodeLabel("11:22:33:44:55:66", "Node 2")
|
|
reg.SetNodeRole("11:22:33:44:55:66", "tx")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/export", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.exportConfig(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("exportConfig() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var config map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&config); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
// Check version
|
|
if config["version"] != float64(1) {
|
|
t.Errorf("Expected version to be 1, got %v", config["version"])
|
|
}
|
|
|
|
// Check exported_at exists
|
|
if config["exported_at"] == nil {
|
|
t.Error("Expected exported_at to be present")
|
|
}
|
|
|
|
// Check nodes
|
|
nodes, ok := config["nodes"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("Expected nodes to be an array, got %T", config["nodes"])
|
|
}
|
|
|
|
if len(nodes) != 2 {
|
|
t.Errorf("Expected 2 nodes, got %d", len(nodes))
|
|
}
|
|
}
|
|
|
|
func TestHandlerImportConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "valid import config",
|
|
requestBody: `{"version": 1, "nodes": []}`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "valid import with nodes",
|
|
requestBody: `{"version": 1, "nodes": [{"mac": "AA:BB:CC:DD:EE:FF", "name": "Test"}]}`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "empty object",
|
|
requestBody: `{}`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/import", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
h.importConfig(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("importConfig() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Rebaseline endpoint tests ────────────────────────────────────────────────────
|
|
|
|
func TestHandlerRebaselineAllNodes(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/rebaseline-all", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.rebaselineAllNodes(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("rebaselineAllNodes() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp["ok"] != true {
|
|
t.Errorf("Expected ok to be true, got %v", resp["ok"])
|
|
}
|
|
}
|
|
|
|
// ─── Add virtual node endpoint tests ───────────────────────────────────────────────
|
|
|
|
func TestHandlerAddVirtualNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful virtual node creation",
|
|
requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`,
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
{
|
|
name: "missing MAC address",
|
|
requestBody: `{"name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "empty body",
|
|
requestBody: `{}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "negative coordinates",
|
|
requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": -1.0, "y": -2.0, "z": -3.0}`,
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/virtual", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
h.addVirtualNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("addVirtualNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Update node position endpoint tests ────────────────────────────────────────
|
|
|
|
func TestHandlerUpdateNodePosition(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
wantStatus int
|
|
expectedX float64
|
|
expectedY float64
|
|
expectedZ float64
|
|
}{
|
|
{
|
|
name: "successful position update",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"x": 1.5, "y": 2.5, "z": 3.5}`,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedX: 1.5,
|
|
expectedY: 2.5,
|
|
expectedZ: 3.5,
|
|
},
|
|
{
|
|
name: "negative coordinates",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"x": -1.0, "y": -2.0, "z": -3.0}`,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedX: -1.0,
|
|
expectedY: -2.0,
|
|
expectedZ: -3.0,
|
|
},
|
|
{
|
|
name: "zero coordinates",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"x": 0, "y": 0, "z": 0}`,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedX: 0,
|
|
expectedY: 0,
|
|
expectedZ: 0,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t);
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3");
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Test Node");
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx");
|
|
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 0, 0, 0);
|
|
|
|
mgr := NewManager(reg);
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("PUT", "/api/nodes/"+tt.mac+"/position", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.updateNodePosition(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("updateNodePosition() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusNoContent {
|
|
// Verify the position was updated
|
|
node, err := reg.GetNode(tt.mac)
|
|
if err != nil {
|
|
t.Errorf("Failed to get node: %v", err)
|
|
} else if node.PosX != tt.expectedX || node.PosY != tt.expectedY || node.PosZ != tt.expectedZ {
|
|
t.Errorf("Expected position to be (%v, %v, %v), got (%v, %v, %v)",
|
|
tt.expectedX, tt.expectedY, tt.expectedZ,
|
|
node.PosX, node.PosY, node.PosZ)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Fleet page specific tests ─────────────────────────────────────────────────────
|
|
|
|
// TestFleetTableRendering verifies that the fleet table renders correctly with 4 nodes
|
|
// and all columns are populated as required by the task specification.
|
|
func TestFleetTableRendering(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Create 4 test nodes with different configurations
|
|
testNodes := []struct {
|
|
mac string
|
|
name string
|
|
role string
|
|
firmware string
|
|
chip string
|
|
x, y, z float64
|
|
health float64
|
|
online bool
|
|
}{
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:01",
|
|
name: "Living Room Node",
|
|
role: "tx",
|
|
firmware: "1.2.0",
|
|
chip: "ESP32-S3",
|
|
x: 0.0,
|
|
y: 0.0,
|
|
z: 2.5,
|
|
health: 0.95,
|
|
online: true,
|
|
},
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:02",
|
|
name: "Kitchen Node",
|
|
role: "rx",
|
|
firmware: "1.1.5",
|
|
chip: "ESP32-S3",
|
|
x: 5.0,
|
|
y: 3.0,
|
|
z: 2.5,
|
|
health: 0.88,
|
|
online: true,
|
|
},
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:03",
|
|
name: "Bedroom Node",
|
|
role: "tx_rx",
|
|
firmware: "1.2.0",
|
|
chip: "ESP32-S3",
|
|
x: 4.0,
|
|
y: 6.0,
|
|
z: 2.5,
|
|
health: 0.92,
|
|
online: false,
|
|
},
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:04",
|
|
name: "Garage Node",
|
|
role: "passive",
|
|
firmware: "1.0.8",
|
|
chip: "ESP32-S3",
|
|
x: 8.0,
|
|
y: 2.0,
|
|
z: 1.5,
|
|
health: 0.75,
|
|
online: true,
|
|
},
|
|
}
|
|
|
|
// Setup connected MACs for online nodes
|
|
var connectedMACs []string
|
|
for _, node := range testNodes {
|
|
reg.UpsertNode(node.mac, node.firmware, node.chip)
|
|
reg.SetNodeLabel(node.mac, node.name)
|
|
reg.SetNodeRole(node.mac, node.role)
|
|
reg.SetNodePosition(node.mac, node.x, node.y, node.z)
|
|
if node.online {
|
|
connectedMACs = append(connectedMACs, node.mac)
|
|
}
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return connectedMACs
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
nodes := resp.Nodes
|
|
|
|
// Verify we have exactly 4 nodes
|
|
if len(nodes) != 4 {
|
|
t.Fatalf("Expected 4 nodes, got %d", len(nodes))
|
|
}
|
|
|
|
// Verify all columns are populated for each node
|
|
for i, node := range nodes {
|
|
// Verify MAC address is present
|
|
if node.MAC == "" {
|
|
t.Errorf("Node %d: MAC address is empty", i)
|
|
}
|
|
|
|
// Verify Label is populated
|
|
if node.Label == "" {
|
|
t.Errorf("Node %d: Label is empty", i)
|
|
}
|
|
|
|
// Verify Role is valid
|
|
validRoles := map[string]bool{"tx": true, "rx": true, "tx_rx": true, "passive": true, "virtual": true}
|
|
if !validRoles[node.Role] {
|
|
t.Errorf("Node %d: Invalid role '%s'", i, node.Role)
|
|
}
|
|
|
|
// Verify Status is either online or offline
|
|
if node.Status != "online" && node.Status != "offline" {
|
|
t.Errorf("Node %d: Invalid status '%s', expected 'online' or 'offline'", i, node.Status)
|
|
}
|
|
|
|
// Verify FirmwareVersion is present
|
|
if node.FirmwareVersion == "" {
|
|
t.Errorf("Node %d: FirmwareVersion is empty", i)
|
|
}
|
|
|
|
// Verify ChipModel is present
|
|
if node.ChipModel == "" {
|
|
t.Errorf("Node %d: ChipModel is empty", i)
|
|
}
|
|
|
|
// Verify HealthScore is between 0 and 1
|
|
if node.HealthScore < 0 || node.HealthScore > 1 {
|
|
t.Errorf("Node %d: HealthScore %v is out of range [0,1]", i, node.HealthScore)
|
|
}
|
|
|
|
// Verify LastSeenMS is set
|
|
if node.LastSeenMS == 0 {
|
|
t.Errorf("Node %d: LastSeenMS is 0", i)
|
|
}
|
|
|
|
// Verify UptimeSeconds is non-negative
|
|
if node.UptimeSeconds < 0 {
|
|
t.Errorf("Node %d: UptimeSeconds is negative: %d", i, node.UptimeSeconds)
|
|
}
|
|
|
|
// Verify ConfiguredRate is set (should default to 20)
|
|
if node.ConfiguredRate != 20 {
|
|
t.Errorf("Node %d: ConfiguredRate is %d, expected 20", i, node.ConfiguredRate)
|
|
}
|
|
}
|
|
|
|
// Verify specific node properties match what we set
|
|
nodeMap := make(map[string]FleetNode)
|
|
for _, node := range nodes {
|
|
nodeMap[node.MAC] = node
|
|
}
|
|
|
|
// Check first node (Living Room - online)
|
|
livingRoom, ok := nodeMap["AA:BB:CC:DD:EE:01"]
|
|
if !ok {
|
|
t.Fatal("Living Room node not found")
|
|
}
|
|
if livingRoom.Label != "Living Room Node" {
|
|
t.Errorf("Living Room: expected label 'Living Room Node', got '%s'", livingRoom.Label)
|
|
}
|
|
if livingRoom.Role != "tx" {
|
|
t.Errorf("Living Room: expected role 'tx', got '%s'", livingRoom.Role)
|
|
}
|
|
if livingRoom.Status != "online" {
|
|
t.Errorf("Living Room: expected status 'online', got '%s'", livingRoom.Status)
|
|
}
|
|
if livingRoom.FirmwareVersion != "1.2.0" {
|
|
t.Errorf("Living Room: expected firmware '1.2.0', got '%s'", livingRoom.FirmwareVersion)
|
|
}
|
|
|
|
// Check third node (Bedroom - offline)
|
|
bedroom, ok := nodeMap["AA:BB:CC:DD:EE:03"]
|
|
if !ok {
|
|
t.Fatal("Bedroom node not found")
|
|
}
|
|
if bedroom.Status != "offline" {
|
|
t.Errorf("Bedroom: expected status 'offline', got '%s'", bedroom.Status)
|
|
}
|
|
if bedroom.Role != "tx_rx" {
|
|
t.Errorf("Bedroom: expected role 'tx_rx', got '%s'", bedroom.Role)
|
|
}
|
|
}
|
|
|
|
// TestFleetNodeFields verifies that all required FleetNode fields are properly
|
|
// computed and returned by the API.
|
|
func TestFleetNodeFields(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Test Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 1.5, 2.5, 3.0)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
nodes := resp.Nodes
|
|
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("Expected 1 node, got %d", len(nodes))
|
|
}
|
|
|
|
node := nodes[0]
|
|
|
|
// Verify all 11 table columns have data:
|
|
// 1. Checkbox - represented by MAC for selection
|
|
if node.MAC != "AA:BB:CC:DD:EE:FF" {
|
|
t.Errorf("MAC: expected 'AA:BB:CC:DD:EE:FF', got '%s'", node.MAC)
|
|
}
|
|
|
|
// 2. Label
|
|
if node.Label != "Test Node" {
|
|
t.Errorf("Label: expected 'Test Node', got '%s'", node.Label)
|
|
}
|
|
|
|
// 3. MAC address (duplicate in table)
|
|
if node.MAC == "" {
|
|
t.Error("MAC address is empty")
|
|
}
|
|
|
|
// 4. Status
|
|
if node.Status != "online" {
|
|
t.Errorf("Status: expected 'online', got '%s'", node.Status)
|
|
}
|
|
|
|
// 5. Firmware version
|
|
if node.FirmwareVersion != "1.2.0" {
|
|
t.Errorf("FirmwareVersion: expected '1.2.0', got '%s'", node.FirmwareVersion)
|
|
}
|
|
|
|
// 6. Uptime (computed field) - can be 0 for newly created test nodes
|
|
if node.UptimeSeconds < 0 {
|
|
t.Errorf("UptimeSeconds: expected non-negative value, got %d", node.UptimeSeconds)
|
|
}
|
|
|
|
// 7. Role
|
|
if node.Role != "rx" {
|
|
t.Errorf("Role: expected 'rx', got '%s'", node.Role)
|
|
}
|
|
|
|
// 8. Signal health (HealthScore)
|
|
if node.HealthScore < 0 || node.HealthScore > 1 {
|
|
t.Errorf("HealthScore: expected value in [0,1], got %v", node.HealthScore)
|
|
}
|
|
|
|
// 9. Packet rate (computed field)
|
|
if node.PacketRate < 0 {
|
|
t.Errorf("PacketRate: expected non-negative value, got %v", node.PacketRate)
|
|
}
|
|
|
|
// 10. Temperature (currently returns 0 as placeholder)
|
|
if node.Temperature != 0 {
|
|
t.Logf("Temperature: got %v (currently returns 0)", node.Temperature)
|
|
}
|
|
|
|
// 11. Actions - represented by MAC for API calls
|
|
if node.MAC == "" {
|
|
t.Error("MAC is empty, needed for action buttons")
|
|
}
|
|
}
|
|
|
|
// TestFleetWithVirtualNodes verifies that virtual nodes are included in the fleet list.
|
|
func TestFleetWithVirtualNodes(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Add a real node
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Real Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
|
|
// Add a virtual node
|
|
reg.AddVirtualNode("11:22:33:44:55:66", "Virtual Node", 2.0, 3.0, 4.0)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
nodes := resp.Nodes
|
|
|
|
if len(nodes) != 2 {
|
|
t.Fatalf("Expected 2 nodes, got %d", len(nodes))
|
|
}
|
|
|
|
// Verify virtual node is marked correctly
|
|
var virtualNode *FleetNode
|
|
for i := range nodes {
|
|
if nodes[i].MAC == "11:22:33:44:55:66" {
|
|
virtualNode = &nodes[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if virtualNode == nil {
|
|
t.Fatal("Virtual node not found in fleet list")
|
|
}
|
|
|
|
if !virtualNode.Virtual {
|
|
t.Error("Virtual node should have Virtual field set to true")
|
|
}
|
|
|
|
if virtualNode.Label != "Virtual Node" {
|
|
t.Errorf("Virtual node label: expected 'Virtual Node', got '%s'", virtualNode.Label)
|
|
}
|
|
|
|
if virtualNode.Role != "virtual" {
|
|
t.Errorf("Virtual node role: expected 'virtual', got '%s'", virtualNode.Role)
|
|
}
|
|
|
|
if virtualNode.Status != "offline" {
|
|
// Virtual nodes are not connected, so they should be offline
|
|
t.Errorf("Virtual node status: expected 'offline', got '%s'", virtualNode.Status)
|
|
}
|
|
}
|
|
|
|
// TestFleetWithNoNodes verifies the API returns an empty array when no nodes exist.
|
|
func TestFleetWithNoNodes(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(resp.Nodes) != 0 {
|
|
t.Errorf("Expected 0 nodes, got %d", len(resp.Nodes))
|
|
}
|
|
}
|
|
|
|
// TestFleetNodeStatusOffline verifies that offline nodes are correctly identified.
|
|
func TestFleetNodeStatusOffline(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Add a node but don't include it in connected list
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Offline Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{} // No connected nodes
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
nodes := resp.Nodes
|
|
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("Expected 1 node, got %d", len(nodes))
|
|
}
|
|
|
|
if nodes[0].Status != "offline" {
|
|
t.Errorf("Expected status 'offline', got '%s'", nodes[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestFleetWithUnpairedNode verifies that unpaired nodes are correctly flagged
|
|
// and show "unpaired" status in the fleet list.
|
|
func TestFleetWithUnpairedNode(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Add a regular online node
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:01", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:01", "Paired Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:01", "rx")
|
|
|
|
// Add an unpaired node (connected without valid token)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:02", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:02", "Unpaired Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:02", "rx")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"}
|
|
},
|
|
getUnpairedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:02"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(resp.Nodes) != 2 {
|
|
t.Fatalf("Expected 2 nodes, got %d", len(resp.Nodes))
|
|
}
|
|
|
|
nodeMap := make(map[string]FleetNode)
|
|
for _, n := range resp.Nodes {
|
|
nodeMap[n.MAC] = n
|
|
}
|
|
|
|
// Paired node should be online
|
|
paired, ok := nodeMap["AA:BB:CC:DD:EE:01"]
|
|
if !ok {
|
|
t.Fatal("Paired node not found")
|
|
}
|
|
if paired.Status != "online" {
|
|
t.Errorf("Paired node: expected status 'online', got '%s'", paired.Status)
|
|
}
|
|
if paired.Unpaired {
|
|
t.Error("Paired node should not be marked as unpaired")
|
|
}
|
|
|
|
// Unpaired node should have unpaired status and flag
|
|
unpaired, ok := nodeMap["AA:BB:CC:DD:EE:02"]
|
|
if !ok {
|
|
t.Fatal("Unpaired node not found")
|
|
}
|
|
if unpaired.Status != "unpaired" {
|
|
t.Errorf("Unpaired node: expected status 'unpaired', got '%s'", unpaired.Status)
|
|
}
|
|
if !unpaired.Unpaired {
|
|
t.Error("Unpaired node should have Unpaired=true")
|
|
}
|
|
}
|
|
|
|
// TestFleetAllUnpaired verifies that when all connected nodes are unpaired,
|
|
// the fleet list still returns them with the correct status.
|
|
func TestFleetAllUnpaired(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Unpaired Only")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
getUnpairedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
nodes := resp.Nodes
|
|
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("Expected 1 node, got %d", len(nodes))
|
|
}
|
|
|
|
if nodes[0].Status != "unpaired" {
|
|
t.Errorf("Expected status 'unpaired', got '%s'", nodes[0].Status)
|
|
}
|
|
if !nodes[0].Unpaired {
|
|
t.Error("Expected Unpaired=true")
|
|
}
|
|
}
|
|
|
|
// TestFleetListMigrationWindowActive verifies that migration window metadata
|
|
// is returned when the deadline is in the future.
|
|
func TestFleetListMigrationWindowActive(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
deadline := time.Now().Add(12 * time.Hour)
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
migProvider: &mockMigProvider{deadline: deadline},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListFullResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if !resp.MigrationWindowActive {
|
|
t.Error("Expected migration_window_active to be true")
|
|
}
|
|
if resp.MigrationDeadlineMS == 0 {
|
|
t.Error("Expected migration_deadline_ms to be set")
|
|
}
|
|
if resp.MigrationRemainingSecs <= 0 {
|
|
t.Errorf("Expected migration_remaining_secs > 0, got %v", resp.MigrationRemainingSecs)
|
|
}
|
|
}
|
|
|
|
// TestFleetListMigrationWindowClosed verifies that the migration window is
|
|
// reported as inactive when the deadline has passed.
|
|
func TestFleetListMigrationWindowClosed(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
// Deadline in the past
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
migProvider: &mockMigProvider{deadline: time.Now().Add(-1 * time.Hour)},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListFullResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.MigrationWindowActive {
|
|
t.Error("Expected migration_window_active to be false when deadline has passed")
|
|
}
|
|
}
|
|
|
|
// TestFleetListNoMigrationWindow verifies that migration window fields are
|
|
// absent when no migration deadline provider is configured.
|
|
func TestFleetListNoMigrationWindow(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
// No migProvider set
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListFullResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.MigrationWindowActive {
|
|
t.Error("Expected migration_window_active to be false with no provider")
|
|
}
|
|
if resp.MigrationDeadlineMS != 0 {
|
|
t.Error("Expected migration_deadline_ms to be 0 with no provider")
|
|
}
|
|
}
|
|
|
|
// TestFleetListUnpairedNotInRegistry verifies that an unpaired node whose
|
|
// MAC is not in the registry is still appended to the fleet list.
|
|
func TestFleetListUnpairedNotInRegistry(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Only one registered node
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:01", "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:01", "Registered Node")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"}
|
|
},
|
|
getUnpairedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:02"} // Not in registry
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp fleetListResp
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { //nolint:errcheck
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(resp.Nodes) != 2 {
|
|
t.Fatalf("Expected 2 nodes (1 registered + 1 unregistered unpaired), got %d", len(resp.Nodes))
|
|
}
|
|
|
|
nodeMap := make(map[string]FleetNode)
|
|
for _, n := range resp.Nodes {
|
|
nodeMap[n.MAC] = n
|
|
}
|
|
|
|
// Registered node should be online
|
|
regNode, ok := nodeMap["AA:BB:CC:DD:EE:01"]
|
|
if !ok {
|
|
t.Fatal("Registered node not found")
|
|
}
|
|
if regNode.Status != "online" {
|
|
t.Errorf("Registered node: expected status 'online', got '%s'", regNode.Status)
|
|
}
|
|
if regNode.Unpaired {
|
|
t.Error("Registered node should not be unpaired")
|
|
}
|
|
|
|
// Unregistered unpaired node should be present with unpaired status
|
|
unregNode, ok := nodeMap["AA:BB:CC:DD:EE:02"]
|
|
if !ok {
|
|
t.Fatal("Unregistered unpaired node not found in fleet list")
|
|
}
|
|
if unregNode.Status != "unpaired" {
|
|
t.Errorf("Unregistered unpaired node: expected status 'unpaired', got '%s'", unregNode.Status)
|
|
}
|
|
if !unregNode.Unpaired {
|
|
t.Error("Unregistered unpaired node should have Unpaired=true")
|
|
}
|
|
if unregNode.Role != "rx" {
|
|
t.Errorf("Unregistered unpaired node: expected default role 'rx', got '%s'", unregNode.Role)
|
|
}
|
|
}
|
|
|
|
// ─── Disable node endpoint tests ───────────────────────────────────────────────────
|
|
|
|
func TestHandlerDisableNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
initialRole string
|
|
nodeExists bool
|
|
wantStatus int
|
|
expectedRole string
|
|
expectedPrior string
|
|
}{
|
|
{
|
|
name: "successful disable from tx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "tx",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "idle",
|
|
expectedPrior: "tx",
|
|
},
|
|
{
|
|
name: "successful disable from rx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "rx",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "idle",
|
|
expectedPrior: "rx",
|
|
},
|
|
{
|
|
name: "successful disable from tx_rx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "tx_rx",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "idle",
|
|
expectedPrior: "tx_rx",
|
|
},
|
|
{
|
|
name: "node already idle returns success",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "idle",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "idle",
|
|
expectedPrior: "",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "tx",
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, tt.initialRole)
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/disable", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.disableNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("disableNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusOK && tt.nodeExists && tt.initialRole != "idle" {
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp["ok"] != true {
|
|
t.Errorf("Expected ok to be true, got %v", resp["ok"])
|
|
}
|
|
|
|
if resp["current_role"] != tt.expectedRole {
|
|
t.Errorf("Expected current_role to be %s, got %v", tt.expectedRole, resp["current_role"])
|
|
}
|
|
|
|
if resp["prior_role"] != tt.expectedPrior {
|
|
t.Errorf("Expected prior_role to be %s, got %v", tt.expectedPrior, resp["prior_role"])
|
|
}
|
|
|
|
// Verify the role was saved to role_before_disable
|
|
savedRole, err := reg.GetNodeRoleBeforeDisable(tt.mac)
|
|
if err != nil {
|
|
t.Errorf("Failed to get role_before_disable: %v", err)
|
|
}
|
|
if savedRole != tt.expectedPrior {
|
|
t.Errorf("Expected role_before_disable to be %s, got %s", tt.expectedPrior, savedRole)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Enable node endpoint tests ───────────────────────────────────────────────────
|
|
|
|
func TestHandlerEnableNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
initialRole string
|
|
savedPriorRole string
|
|
nodeExists bool
|
|
wantStatus int
|
|
expectedRole string
|
|
expectedNote string
|
|
}{
|
|
{
|
|
name: "successful enable from idle with saved prior role",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "idle",
|
|
savedPriorRole: "tx",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "tx",
|
|
},
|
|
{
|
|
name: "successful enable from idle with rx saved",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "idle",
|
|
savedPriorRole: "rx",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "rx",
|
|
},
|
|
{
|
|
name: "successful enable from idle with tx_rx saved",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "idle",
|
|
savedPriorRole: "tx_rx",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "tx_rx",
|
|
},
|
|
{
|
|
name: "enable idle node with no saved role defaults to rx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "idle",
|
|
savedPriorRole: "",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "rx",
|
|
},
|
|
{
|
|
name: "node already enabled returns current state",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "tx",
|
|
savedPriorRole: "",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "tx",
|
|
expectedNote: "node already enabled",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
initialRole: "idle",
|
|
savedPriorRole: "tx",
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, tt.initialRole)
|
|
if tt.savedPriorRole != "" {
|
|
reg.SetNodeRoleBeforeDisable(tt.mac, tt.savedPriorRole)
|
|
}
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/enable", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.enableNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("enableNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusOK && tt.nodeExists {
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp["ok"] != true {
|
|
t.Errorf("Expected ok to be true, got %v", resp["ok"])
|
|
}
|
|
|
|
// When node is already enabled, response contains current_role instead of restored_role
|
|
roleKey := "restored_role"
|
|
if tt.expectedNote != "" {
|
|
roleKey = "current_role"
|
|
if resp["note"] != tt.expectedNote {
|
|
t.Errorf("Expected note to be %s, got %v", tt.expectedNote, resp["note"])
|
|
}
|
|
}
|
|
|
|
if resp[roleKey] != tt.expectedRole {
|
|
t.Errorf("Expected %s to be %s, got %v", roleKey, tt.expectedRole, resp[roleKey])
|
|
}
|
|
|
|
// Verify the node's current role was updated
|
|
node, err := reg.GetNode(tt.mac)
|
|
if err != nil {
|
|
t.Errorf("Failed to get node after enable: %v", err)
|
|
} else if node.Role != tt.expectedRole {
|
|
t.Errorf("Expected node role to be %s, got %s", tt.expectedRole, node.Role)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandlerDisableEnableRoundTrip tests the full disable/enable cycle.
|
|
func TestHandlerDisableEnableRoundTrip(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
mac := "AA:BB:CC:DD:EE:FF"
|
|
|
|
// Setup node with initial role
|
|
reg.UpsertNode(mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(mac, "Test Node")
|
|
reg.SetNodeRole(mac, "tx")
|
|
|
|
mgr := NewManager(reg)
|
|
h := &Handler{mgr: mgr}
|
|
|
|
// Disable the node
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+mac+"/disable", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.disableNode(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("disableNode() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
// Verify role is now idle
|
|
node, err := reg.GetNode(mac)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get node after disable: %v", err)
|
|
}
|
|
if node.Role != "idle" {
|
|
t.Errorf("Expected role to be idle after disable, got %s", node.Role)
|
|
}
|
|
|
|
// Verify prior role was saved
|
|
savedRole, err := reg.GetNodeRoleBeforeDisable(mac)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get role_before_disable: %v", err)
|
|
}
|
|
if savedRole != "tx" {
|
|
t.Errorf("Expected role_before_disable to be tx, got %s", savedRole)
|
|
}
|
|
|
|
// Enable the node
|
|
req = httptest.NewRequest("POST", "/api/nodes/"+mac+"/enable", nil)
|
|
rctx = chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w = httptest.NewRecorder()
|
|
h.enableNode(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("enableNode() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
// Verify role was restored
|
|
node, err = reg.GetNode(mac)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get node after enable: %v", err)
|
|
}
|
|
if node.Role != "tx" {
|
|
t.Errorf("Expected role to be tx after enable, got %s", node.Role)
|
|
}
|
|
|
|
// Verify role_before_disable was cleared
|
|
savedRole, err = reg.GetNodeRoleBeforeDisable(mac)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get role_before_disable after enable: %v", err)
|
|
}
|
|
if savedRole != "" {
|
|
t.Errorf("Expected role_before_disable to be cleared after enable, got %s", savedRole)
|
|
}
|
|
}
|
|
|
|
// TestRouteRegistrationNoPanic verifies that registering both Handler and FleetHandler
|
|
// on the same router does not cause chi to panic with duplicate route registration.
|
|
// This is a startup smoke test to catch accidental route conflicts early.
|
|
func TestRouteRegistrationNoPanic(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
mgr := NewManager(reg)
|
|
selfHealMgr := NewSelfHealManager(reg, NewRoleOptimiser(DefaultOptimisationConfig()), DefaultSelfHealConfig())
|
|
|
|
// Create both handlers as main.go does
|
|
h := NewHandler(mgr)
|
|
fleetHealthHandler := NewFleetHandler(selfHealMgr, reg)
|
|
|
|
// This should not panic - if there are duplicate routes, chi will panic here
|
|
r := chi.NewRouter()
|
|
h.RegisterRoutes(r)
|
|
fleetHealthHandler.RegisterRoutes(r)
|
|
|
|
// Verify expected routes are registered
|
|
// From Handler: /api/nodes, /api/fleet (list), /api/nodes/{mac}/role, etc.
|
|
// From FleetHandler: /api/fleet/health, /api/fleet/history, /api/fleet/optimise, /api/fleet/simulate
|
|
// The key is no panic on duplicate routes like POST /api/nodes/{mac}/role
|
|
|
|
// Try to walk the routes to ensure they're registered
|
|
routes := chi.Routes(r)
|
|
if len(routes) == 0 {
|
|
t.Fatal("No routes registered")
|
|
}
|
|
|
|
// Verify some expected routes exist
|
|
expectedRoutes := []string{
|
|
"/api/nodes",
|
|
"/api/fleet",
|
|
"/api/fleet/health",
|
|
"/api/fleet/history",
|
|
"/api/fleet/optimise",
|
|
"/api/fleet/simulate",
|
|
}
|
|
|
|
for _, expected := range expectedRoutes {
|
|
found := false
|
|
for _, route := range routes {
|
|
if route.Pattern == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Expected route %s not found", expected)
|
|
}
|
|
}
|
|
}
|