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