The writeJSON function uses json.NewEncoder which adds a newline character. Changed raw string literals to interpreted strings so \n becomes an actual newline character.
1833 lines
47 KiB
Go
1833 lines
47 KiB
Go
package fleet
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// mockNodeIdentifier is a mock implementation of NodeIdentifier for testing.
|
|
type mockNodeIdentifier struct {
|
|
sendIdentifyFunc func(mac string, durationMS int) bool
|
|
sendRebootFunc func(mac string, delayMS int) bool
|
|
getConnectedMACs func() []string
|
|
}
|
|
|
|
func (m *mockNodeIdentifier) SendIdentifyToMAC(mac string, durationMS int) bool {
|
|
if m.sendIdentifyFunc != nil {
|
|
return m.sendIdentifyFunc(mac, durationMS)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *mockNodeIdentifier) SendRebootToMAC(mac string, delayMS int) bool {
|
|
if m.sendRebootFunc != nil {
|
|
return m.sendRebootFunc(mac, delayMS)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *mockNodeIdentifier) GetConnectedMACs() []string {
|
|
if m.getConnectedMACs != nil {
|
|
return m.getConnectedMACs()
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
// mockRegistry is a mock implementation of Registry for testing.
|
|
type mockRegistry struct {
|
|
nodes map[string]NodeRecord
|
|
}
|
|
|
|
func (m *mockRegistry) GetNode(mac string) (*NodeRecord, error) {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
return &node, nil
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) GetAllNodes() ([]NodeRecord, error) {
|
|
var nodes []NodeRecord
|
|
for _, node := range m.nodes {
|
|
nodes = append(nodes, node)
|
|
}
|
|
return nodes, nil
|
|
}
|
|
|
|
func (m *mockRegistry) SetNodeLabel(mac, label string) error {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
node.Name = label
|
|
m.nodes[mac] = node
|
|
return nil
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) SetNodePosition(mac string, x, y, z float64) error {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
node.PosX = x
|
|
node.PosY = y
|
|
node.PosZ = z
|
|
m.nodes[mac] = node
|
|
return nil
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) AddVirtualNode(mac, name string, x, y, z float64) error {
|
|
m.nodes[mac] = NodeRecord{
|
|
MAC: mac,
|
|
Name: name,
|
|
Role: "virtual",
|
|
PosX: x,
|
|
PosY: y,
|
|
PosZ: z,
|
|
Virtual: true,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRegistry) DeleteNode(mac string) error {
|
|
delete(m.nodes, mac)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRegistry) UpsertNode(mac, firmware, chip string) error {
|
|
if _, ok := m.nodes[mac]; !ok {
|
|
m.nodes[mac] = NodeRecord{
|
|
MAC: mac,
|
|
Name: "",
|
|
Role: "rx",
|
|
FirmwareVersion: firmware,
|
|
ChipModel: chip,
|
|
}
|
|
} else {
|
|
node := m.nodes[mac]
|
|
node.FirmwareVersion = firmware
|
|
node.ChipModel = chip
|
|
m.nodes[mac] = node
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRegistry) SetNodeRole(mac, role string) error {
|
|
if node, ok := m.nodes[mac]; ok {
|
|
node.Role = role
|
|
m.nodes[mac] = node
|
|
return nil
|
|
}
|
|
return sql.ErrNoRows
|
|
}
|
|
|
|
func (m *mockRegistry) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func TestHandlerIdentifyNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
reqBody string
|
|
nodeExists bool
|
|
nodeConnected bool
|
|
wantStatus int
|
|
wantResponse string
|
|
}{
|
|
{
|
|
name: "successful identify with default duration",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
{
|
|
name: "successful identify with custom duration",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{"duration_ms": 10000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{}`,
|
|
nodeExists: false,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusNotFound,
|
|
wantResponse: "node not found\n",
|
|
},
|
|
{
|
|
name: "node not connected",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: false,
|
|
wantStatus: http.StatusNotFound,
|
|
wantResponse: "node not connected\n",
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `invalid json`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantResponse: "invalid request body\n",
|
|
},
|
|
{
|
|
name: "zero duration uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{"duration_ms": 0}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
{
|
|
name: "negative duration uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
reqBody: `{"duration_ms": -1000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
wantResponse: "{\"ok\":true}\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create a real registry with the test node
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
err := reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
if err != nil {
|
|
t.Fatalf("UpsertNode: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create a manager with the registry
|
|
mgr := NewManager(reg)
|
|
|
|
// Create handler with mock node identifier
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendIdentifyFunc: func(mac string, durationMS int) bool {
|
|
return tt.nodeConnected
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a test request
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/identify", bytes.NewBufferString(tt.reqBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Use chi URLParam to set the MAC parameter
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
// Create response recorder
|
|
w := httptest.NewRecorder()
|
|
|
|
// Call the handler
|
|
h.identifyNode(w, req)
|
|
|
|
// Check status code
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("identifyNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
// Check response body
|
|
if tt.wantResponse != "" {
|
|
resp := w.Body.String()
|
|
if resp != tt.wantResponse {
|
|
t.Errorf("identifyNode() response = %q, want %q", resp, tt.wantResponse)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerIdentifyNodeDurationParsing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reqBody string
|
|
expectedDuration int
|
|
}{
|
|
{
|
|
name: "default duration when not specified",
|
|
reqBody: `{}`,
|
|
expectedDuration: 5000,
|
|
},
|
|
{
|
|
name: "custom duration",
|
|
reqBody: `{"duration_ms": 10000}`,
|
|
expectedDuration: 10000,
|
|
},
|
|
{
|
|
name: "zero uses default",
|
|
reqBody: `{"duration_ms": 0}`,
|
|
expectedDuration: 5000,
|
|
},
|
|
{
|
|
name: "negative uses default",
|
|
reqBody: `{"duration_ms": -1000}`,
|
|
expectedDuration: 5000,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var actualDuration int
|
|
|
|
reg := newTestRegistry(t)
|
|
err := reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
if err != nil {
|
|
t.Fatalf("UpsertNode: %v", err)
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendIdentifyFunc: func(mac string, durationMS int) bool {
|
|
actualDuration = durationMS
|
|
return true
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/AA:BB:CC:DD:EE:FF/identify", bytes.NewBufferString(tt.reqBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", "AA:BB:CC:DD:EE:FF")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.identifyNode(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status OK, got %v", w.Code)
|
|
}
|
|
|
|
if actualDuration != tt.expectedDuration {
|
|
t.Errorf("Duration = %v, want %v", actualDuration, tt.expectedDuration)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIdentifyNodeRequest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid empty object",
|
|
json: `{}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid with duration",
|
|
json: `{"duration_ms": 10000}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid with zero duration",
|
|
json: `{"duration_ms": 0}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid with negative duration",
|
|
json: `{"duration_ms": -1000}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
json: `invalid`,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "extra fields ignored",
|
|
json: `{"duration_ms": 5000, "extra": "ignored"}`,
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var req identifyNodeRequest
|
|
err := json.NewDecoder(bytes.NewBufferString(tt.json)).Decode(&req)
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("json.NewDecoder().Decode() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── System mode endpoint tests ─────────────────────────────────────────────
|
|
|
|
func TestHandlerGetSystemMode(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
mgr := NewManager(reg)
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/mode", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.getSystemMode(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("getSystemMode() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp systemModeResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.Mode != "home" {
|
|
t.Errorf("Expected mode to be home, got %s", resp.Mode)
|
|
}
|
|
|
|
if !resp.AutoAwayConfig.Enabled {
|
|
t.Errorf("Expected auto-away to be enabled by default")
|
|
}
|
|
|
|
if resp.AutoAwayConfig.AbsenceDurationSec != 900 { // 15 minutes
|
|
t.Errorf("Expected absence duration to be 900s, got %d", resp.AutoAwayConfig.AbsenceDurationSec)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSetSystemMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
wantStatus int
|
|
expectedMode string
|
|
}{
|
|
{
|
|
name: "set to away mode",
|
|
requestBody: `{"mode": "away", "reason": "manual"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "away",
|
|
},
|
|
{
|
|
name: "set to home mode",
|
|
requestBody: `{"mode": "home", "reason": "manual"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "home",
|
|
},
|
|
{
|
|
name: "set to sleep mode",
|
|
requestBody: `{"mode": "sleep", "reason": "night"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "sleep",
|
|
},
|
|
{
|
|
name: "mode defaults reason to manual",
|
|
requestBody: `{"mode": "away"}`,
|
|
wantStatus: http.StatusOK,
|
|
expectedMode: "away",
|
|
},
|
|
{
|
|
name: "invalid mode",
|
|
requestBody: `{"mode": "invalid"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/mode", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
h.setSystemMode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("setSystemMode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusOK {
|
|
var resp systemModeResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.Mode != tt.expectedMode {
|
|
t.Errorf("Expected mode to be %s, got %s", tt.expectedMode, resp.Mode)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Fleet list endpoint tests ────────────────────────────────────────────────
|
|
|
|
func TestHandlerListFleet(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
|
|
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 1.0, 2.0, 3.0)
|
|
reg.UpsertNode("11:22:33:44:55:66", "1.1.0", "ESP32-S3")
|
|
reg.SetNodeLabel("11:22:33:44:55:66", "Node 2")
|
|
reg.SetNodePosition("11:22:33:44:55:66", 4.0, 5.0, 6.0)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendIdentifyFunc: func(mac string, durationMS int) bool {
|
|
return true
|
|
},
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var nodes []FleetNode
|
|
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(nodes) != 2 {
|
|
t.Errorf("Expected 2 nodes, got %d", len(nodes))
|
|
}
|
|
|
|
// Check first node (should be online)
|
|
if nodes[0].MAC != "AA:BB:CC:DD:EE:FF" {
|
|
t.Errorf("Expected first node MAC to be AA:BB:CC:DD:EE:FF, got %s", nodes[0].MAC)
|
|
}
|
|
if nodes[0].Status != "online" {
|
|
t.Errorf("Expected first node status to be online, got %s", nodes[0].Status)
|
|
}
|
|
|
|
// Check second node (should be offline - not in connected list)
|
|
if nodes[1].MAC != "11:22:33:44:55:66" {
|
|
t.Errorf("Expected second node MAC to be 11:22:33:44:55:66, got %s", nodes[1].MAC)
|
|
}
|
|
if nodes[1].Status != "offline" {
|
|
t.Errorf("Expected second node status to be offline, got %s", nodes[1].Status)
|
|
}
|
|
}
|
|
|
|
func TestHandlerListFleetEmpty(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var nodes []FleetNode
|
|
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(nodes) != 0 {
|
|
t.Errorf("Expected 0 nodes, got %d", len(nodes))
|
|
}
|
|
}
|
|
|
|
// ─── Get node endpoint tests ───────────────────────────────────────────────────
|
|
|
|
func TestHandlerGetNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
nodeExists bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "node found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/nodes/"+tt.mac, nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.getNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("getNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusOK {
|
|
var node NodeRecord
|
|
if err := json.NewDecoder(w.Body).Decode(&node); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
if node.MAC != tt.mac {
|
|
t.Errorf("Expected MAC to be %s, got %s", tt.mac, node.MAC)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Update node label endpoint tests ───────────────────────────────────────────
|
|
|
|
func TestHandlerUpdateNodeLabel(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
wantStatus int
|
|
expectedLabel string
|
|
}{
|
|
{
|
|
name: "successful label update",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"label": "New Label"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedLabel: "New Label",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"label": "New Label"}`,
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Old Label")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("PATCH", "/api/nodes/"+tt.mac+"/label", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.updateNodeLabel(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("updateNodeLabel() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusNoContent {
|
|
// Verify the label was updated
|
|
node, err := reg.GetNode(tt.mac)
|
|
if err != nil {
|
|
t.Errorf("Failed to get node: %v", err)
|
|
} else if node.Name != tt.expectedLabel {
|
|
t.Errorf("Expected label to be %s, got %s", tt.expectedLabel, node.Name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Set node role endpoint tests ───────────────────────────────────────────────
|
|
|
|
func TestHandlerSetNodeRole(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
wantStatus int
|
|
expectedRole string
|
|
}{
|
|
{
|
|
name: "successful role change to tx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "tx"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "tx",
|
|
},
|
|
{
|
|
name: "successful role change to rx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "rx"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "rx",
|
|
},
|
|
{
|
|
name: "successful role change to tx_rx",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "tx_rx"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "tx_rx",
|
|
},
|
|
{
|
|
name: "successful role change to passive",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "passive"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusOK,
|
|
expectedRole: "passive",
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "tx"}`,
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid role",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": "invalid"}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "empty role",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"role": ""}`,
|
|
nodeExists: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/role", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.setNodeRole(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("setNodeRole() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Delete node endpoint tests ─────────────────────────────────────────────────
|
|
|
|
func TestHandlerDeleteNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
nodeExists bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful deletion",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: true,
|
|
wantStatus: http.StatusNoContent,
|
|
},
|
|
{
|
|
name: "delete non-existent node succeeds",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
nodeExists: false,
|
|
wantStatus: http.StatusNoContent,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("DELETE", "/api/nodes/"+tt.mac, nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.deleteNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("deleteNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── OTA endpoint tests ───────────────────────────────────────────────────────────
|
|
|
|
func TestHandlerTriggerNodeOTA(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
otaAvailable bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful OTA trigger",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "OTA with specific version",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"version": "1.2.0"}`,
|
|
nodeExists: true,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: false,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
otaAvailable: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "OTA manager not available",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
otaAvailable: false,
|
|
wantStatus: http.StatusOK, // Still succeeds without OTA manager
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
if tt.otaAvailable {
|
|
// OTA manager is optional in the handler
|
|
h.otaMgr = nil // Mock would go here
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/ota", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.triggerNodeOTA(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("triggerNodeOTA() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Reboot endpoint tests ─────────────────────────────────────────────────────────
|
|
|
|
func TestHandlerRebootNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
nodeExists bool
|
|
nodeConnected bool
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful reboot with default delay",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "successful reboot with custom delay",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"delay_ms": 5000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "node not found",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: false,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "node not connected",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{}`,
|
|
nodeExists: true,
|
|
nodeConnected: false,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "zero delay uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"delay_ms": 0}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "negative delay uses default",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"delay_ms": -1000}`,
|
|
nodeExists: true,
|
|
nodeConnected: true,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
if tt.nodeExists {
|
|
reg.UpsertNode(tt.mac, "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel(tt.mac, "Test Node")
|
|
reg.SetNodeRole(tt.mac, "rx")
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
sendRebootFunc: func(mac string, delayMS int) bool {
|
|
return tt.nodeConnected
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/"+tt.mac+"/reboot", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.rebootNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("rebootNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Update all endpoint tests ────────────────────────────────────────────────────
|
|
|
|
func TestHandlerUpdateAllNodes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
connectedMACs []string
|
|
wantStatus int
|
|
expectedCount int
|
|
}{
|
|
{
|
|
name: "update all connected nodes",
|
|
connectedMACs: []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"},
|
|
wantStatus: http.StatusOK,
|
|
expectedCount: 2,
|
|
},
|
|
{
|
|
name: "no connected nodes",
|
|
connectedMACs: []string{},
|
|
wantStatus: http.StatusOK,
|
|
expectedCount: 0,
|
|
},
|
|
{
|
|
name: "single connected node",
|
|
connectedMACs: []string{"AA:BB:CC:DD:EE:FF"},
|
|
wantStatus: http.StatusOK,
|
|
expectedCount: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return tt.connectedMACs
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/update-all", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.updateAllNodes(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("updateAllNodes() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
count, ok := resp["count"].(float64)
|
|
if !ok {
|
|
t.Fatalf("Expected count to be a number, got %T", resp["count"])
|
|
}
|
|
|
|
if int(count) != tt.expectedCount {
|
|
t.Errorf("Expected count to be %d, got %d", tt.expectedCount, int(count))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Export/Import endpoint tests ─────────────────────────────────────────────────
|
|
|
|
func TestHandlerExportConfig(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Node 1")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
reg.UpsertNode("11:22:33:44:55:66", "1.1.0", "ESP32-S3")
|
|
reg.SetNodeLabel("11:22:33:44:55:66", "Node 2")
|
|
reg.SetNodeRole("11:22:33:44:55:66", "tx")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/export", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.exportConfig(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("exportConfig() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var config map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&config); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
// Check version
|
|
if config["version"] != float64(1) {
|
|
t.Errorf("Expected version to be 1, got %v", config["version"])
|
|
}
|
|
|
|
// Check exported_at exists
|
|
if config["exported_at"] == nil {
|
|
t.Error("Expected exported_at to be present")
|
|
}
|
|
|
|
// Check nodes
|
|
nodes, ok := config["nodes"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("Expected nodes to be an array, got %T", config["nodes"])
|
|
}
|
|
|
|
if len(nodes) != 2 {
|
|
t.Errorf("Expected 2 nodes, got %d", len(nodes))
|
|
}
|
|
}
|
|
|
|
func TestHandlerImportConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "valid import config",
|
|
requestBody: `{"version": 1, "nodes": []}`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "valid import with nodes",
|
|
requestBody: `{"version": 1, "nodes": [{"mac": "AA:BB:CC:DD:EE:FF", "name": "Test"}]}`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "empty object",
|
|
requestBody: `{}`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/import", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
h.importConfig(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("importConfig() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Rebaseline endpoint tests ────────────────────────────────────────────────────
|
|
|
|
func TestHandlerRebaselineAllNodes(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/rebaseline-all", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.rebaselineAllNodes(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("rebaselineAllNodes() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp["ok"] != true {
|
|
t.Errorf("Expected ok to be true, got %v", resp["ok"])
|
|
}
|
|
}
|
|
|
|
// ─── Add virtual node endpoint tests ───────────────────────────────────────────────
|
|
|
|
func TestHandlerAddVirtualNode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "successful virtual node creation",
|
|
requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`,
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
{
|
|
name: "missing MAC address",
|
|
requestBody: `{"name": "Virtual Node", "x": 1.0, "y": 2.0, "z": 3.0}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "empty body",
|
|
requestBody: `{}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "negative coordinates",
|
|
requestBody: `{"mac": "AA:BB:CC:DD:EE:FF", "name": "Virtual Node", "x": -1.0, "y": -2.0, "z": -3.0}`,
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("POST", "/api/nodes/virtual", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
h.addVirtualNode(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("addVirtualNode() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Update node position endpoint tests ────────────────────────────────────────
|
|
|
|
func TestHandlerUpdateNodePosition(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mac string
|
|
requestBody string
|
|
wantStatus int
|
|
expectedX float64
|
|
expectedY float64
|
|
expectedZ float64
|
|
}{
|
|
{
|
|
name: "successful position update",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"x": 1.5, "y": 2.5, "z": 3.5}`,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedX: 1.5,
|
|
expectedY: 2.5,
|
|
expectedZ: 3.5,
|
|
},
|
|
{
|
|
name: "negative coordinates",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"x": -1.0, "y": -2.0, "z": -3.0}`,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedX: -1.0,
|
|
expectedY: -2.0,
|
|
expectedZ: -3.0,
|
|
},
|
|
{
|
|
name: "zero coordinates",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `{"x": 0, "y": 0, "z": 0}`,
|
|
wantStatus: http.StatusNoContent,
|
|
expectedX: 0,
|
|
expectedY: 0,
|
|
expectedZ: 0,
|
|
},
|
|
{
|
|
name: "invalid request body",
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
requestBody: `invalid json`,
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := newTestRegistry(t);
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3");
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Test Node");
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx");
|
|
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 0, 0, 0);
|
|
|
|
mgr := NewManager(reg);
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("PUT", "/api/nodes/"+tt.mac+"/position", bytes.NewBufferString(tt.requestBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("mac", tt.mac)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.updateNodePosition(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("updateNodePosition() status = %v, want %v", w.Code, tt.wantStatus)
|
|
}
|
|
|
|
if tt.wantStatus == http.StatusNoContent {
|
|
// Verify the position was updated
|
|
node, err := reg.GetNode(tt.mac)
|
|
if err != nil {
|
|
t.Errorf("Failed to get node: %v", err)
|
|
} else if node.PosX != tt.expectedX || node.PosY != tt.expectedY || node.PosZ != tt.expectedZ {
|
|
t.Errorf("Expected position to be (%v, %v, %v), got (%v, %v, %v)",
|
|
tt.expectedX, tt.expectedY, tt.expectedZ,
|
|
node.PosX, node.PosY, node.PosZ)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── Fleet page specific tests ─────────────────────────────────────────────────────
|
|
|
|
// TestFleetTableRendering verifies that the fleet table renders correctly with 4 nodes
|
|
// and all columns are populated as required by the task specification.
|
|
func TestFleetTableRendering(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Create 4 test nodes with different configurations
|
|
testNodes := []struct {
|
|
mac string
|
|
name string
|
|
role string
|
|
firmware string
|
|
chip string
|
|
x, y, z float64
|
|
health float64
|
|
online bool
|
|
}{
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:01",
|
|
name: "Living Room Node",
|
|
role: "tx",
|
|
firmware: "1.2.0",
|
|
chip: "ESP32-S3",
|
|
x: 0.0,
|
|
y: 0.0,
|
|
z: 2.5,
|
|
health: 0.95,
|
|
online: true,
|
|
},
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:02",
|
|
name: "Kitchen Node",
|
|
role: "rx",
|
|
firmware: "1.1.5",
|
|
chip: "ESP32-S3",
|
|
x: 5.0,
|
|
y: 3.0,
|
|
z: 2.5,
|
|
health: 0.88,
|
|
online: true,
|
|
},
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:03",
|
|
name: "Bedroom Node",
|
|
role: "tx_rx",
|
|
firmware: "1.2.0",
|
|
chip: "ESP32-S3",
|
|
x: 4.0,
|
|
y: 6.0,
|
|
z: 2.5,
|
|
health: 0.92,
|
|
online: false,
|
|
},
|
|
{
|
|
mac: "AA:BB:CC:DD:EE:04",
|
|
name: "Garage Node",
|
|
role: "passive",
|
|
firmware: "1.0.8",
|
|
chip: "ESP32-S3",
|
|
x: 8.0,
|
|
y: 2.0,
|
|
z: 1.5,
|
|
health: 0.75,
|
|
online: true,
|
|
},
|
|
}
|
|
|
|
// Setup connected MACs for online nodes
|
|
var connectedMACs []string
|
|
for _, node := range testNodes {
|
|
reg.UpsertNode(node.mac, node.firmware, node.chip)
|
|
reg.SetNodeLabel(node.mac, node.name)
|
|
reg.SetNodeRole(node.mac, node.role)
|
|
reg.SetNodePosition(node.mac, node.x, node.y, node.z)
|
|
if node.online {
|
|
connectedMACs = append(connectedMACs, node.mac)
|
|
}
|
|
}
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return connectedMACs
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var nodes []FleetNode
|
|
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
// Verify we have exactly 4 nodes
|
|
if len(nodes) != 4 {
|
|
t.Fatalf("Expected 4 nodes, got %d", len(nodes))
|
|
}
|
|
|
|
// Verify all columns are populated for each node
|
|
for i, node := range nodes {
|
|
// Verify MAC address is present
|
|
if node.MAC == "" {
|
|
t.Errorf("Node %d: MAC address is empty", i)
|
|
}
|
|
|
|
// Verify Label is populated
|
|
if node.Label == "" {
|
|
t.Errorf("Node %d: Label is empty", i)
|
|
}
|
|
|
|
// Verify Role is valid
|
|
validRoles := map[string]bool{"tx": true, "rx": true, "tx_rx": true, "passive": true, "virtual": true}
|
|
if !validRoles[node.Role] {
|
|
t.Errorf("Node %d: Invalid role '%s'", i, node.Role)
|
|
}
|
|
|
|
// Verify Status is either online or offline
|
|
if node.Status != "online" && node.Status != "offline" {
|
|
t.Errorf("Node %d: Invalid status '%s', expected 'online' or 'offline'", i, node.Status)
|
|
}
|
|
|
|
// Verify FirmwareVersion is present
|
|
if node.FirmwareVersion == "" {
|
|
t.Errorf("Node %d: FirmwareVersion is empty", i)
|
|
}
|
|
|
|
// Verify ChipModel is present
|
|
if node.ChipModel == "" {
|
|
t.Errorf("Node %d: ChipModel is empty", i)
|
|
}
|
|
|
|
// Verify HealthScore is between 0 and 1
|
|
if node.HealthScore < 0 || node.HealthScore > 1 {
|
|
t.Errorf("Node %d: HealthScore %v is out of range [0,1]", i, node.HealthScore)
|
|
}
|
|
|
|
// Verify LastSeenMS is set
|
|
if node.LastSeenMS == 0 {
|
|
t.Errorf("Node %d: LastSeenMS is 0", i)
|
|
}
|
|
|
|
// Verify UptimeSeconds is non-negative
|
|
if node.UptimeSeconds < 0 {
|
|
t.Errorf("Node %d: UptimeSeconds is negative: %d", i, node.UptimeSeconds)
|
|
}
|
|
|
|
// Verify ConfiguredRate is set (should default to 20)
|
|
if node.ConfiguredRate != 20 {
|
|
t.Errorf("Node %d: ConfiguredRate is %d, expected 20", i, node.ConfiguredRate)
|
|
}
|
|
}
|
|
|
|
// Verify specific node properties match what we set
|
|
nodeMap := make(map[string]FleetNode)
|
|
for _, node := range nodes {
|
|
nodeMap[node.MAC] = node
|
|
}
|
|
|
|
// Check first node (Living Room - online)
|
|
livingRoom, ok := nodeMap["AA:BB:CC:DD:EE:01"]
|
|
if !ok {
|
|
t.Fatal("Living Room node not found")
|
|
}
|
|
if livingRoom.Label != "Living Room Node" {
|
|
t.Errorf("Living Room: expected label 'Living Room Node', got '%s'", livingRoom.Label)
|
|
}
|
|
if livingRoom.Role != "tx" {
|
|
t.Errorf("Living Room: expected role 'tx', got '%s'", livingRoom.Role)
|
|
}
|
|
if livingRoom.Status != "online" {
|
|
t.Errorf("Living Room: expected status 'online', got '%s'", livingRoom.Status)
|
|
}
|
|
if livingRoom.FirmwareVersion != "1.2.0" {
|
|
t.Errorf("Living Room: expected firmware '1.2.0', got '%s'", livingRoom.FirmwareVersion)
|
|
}
|
|
|
|
// Check third node (Bedroom - offline)
|
|
bedroom, ok := nodeMap["AA:BB:CC:DD:EE:03"]
|
|
if !ok {
|
|
t.Fatal("Bedroom node not found")
|
|
}
|
|
if bedroom.Status != "offline" {
|
|
t.Errorf("Bedroom: expected status 'offline', got '%s'", bedroom.Status)
|
|
}
|
|
if bedroom.Role != "tx_rx" {
|
|
t.Errorf("Bedroom: expected role 'tx_rx', got '%s'", bedroom.Role)
|
|
}
|
|
}
|
|
|
|
// TestFleetNodeFields verifies that all required FleetNode fields are properly
|
|
// computed and returned by the API.
|
|
func TestFleetNodeFields(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Test Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
reg.SetNodePosition("AA:BB:CC:DD:EE:FF", 1.5, 2.5, 3.0)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var nodes []FleetNode
|
|
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("Expected 1 node, got %d", len(nodes))
|
|
}
|
|
|
|
node := nodes[0]
|
|
|
|
// Verify all 11 table columns have data:
|
|
// 1. Checkbox - represented by MAC for selection
|
|
if node.MAC != "AA:BB:CC:DD:EE:FF" {
|
|
t.Errorf("MAC: expected 'AA:BB:CC:DD:EE:FF', got '%s'", node.MAC)
|
|
}
|
|
|
|
// 2. Label
|
|
if node.Label != "Test Node" {
|
|
t.Errorf("Label: expected 'Test Node', got '%s'", node.Label)
|
|
}
|
|
|
|
// 3. MAC address (duplicate in table)
|
|
if node.MAC == "" {
|
|
t.Error("MAC address is empty")
|
|
}
|
|
|
|
// 4. Status
|
|
if node.Status != "online" {
|
|
t.Errorf("Status: expected 'online', got '%s'", node.Status)
|
|
}
|
|
|
|
// 5. Firmware version
|
|
if node.FirmwareVersion != "1.2.0" {
|
|
t.Errorf("FirmwareVersion: expected '1.2.0', got '%s'", node.FirmwareVersion)
|
|
}
|
|
|
|
// 6. Uptime (computed field) - can be 0 for newly created test nodes
|
|
if node.UptimeSeconds < 0 {
|
|
t.Errorf("UptimeSeconds: expected non-negative value, got %d", node.UptimeSeconds)
|
|
}
|
|
|
|
// 7. Role
|
|
if node.Role != "rx" {
|
|
t.Errorf("Role: expected 'rx', got '%s'", node.Role)
|
|
}
|
|
|
|
// 8. Signal health (HealthScore)
|
|
if node.HealthScore < 0 || node.HealthScore > 1 {
|
|
t.Errorf("HealthScore: expected value in [0,1], got %v", node.HealthScore)
|
|
}
|
|
|
|
// 9. Packet rate (computed field)
|
|
if node.PacketRate < 0 {
|
|
t.Errorf("PacketRate: expected non-negative value, got %v", node.PacketRate)
|
|
}
|
|
|
|
// 10. Temperature (currently returns 0 as placeholder)
|
|
if node.Temperature != 0 {
|
|
t.Logf("Temperature: got %v (currently returns 0)", node.Temperature)
|
|
}
|
|
|
|
// 11. Actions - represented by MAC for API calls
|
|
if node.MAC == "" {
|
|
t.Error("MAC is empty, needed for action buttons")
|
|
}
|
|
}
|
|
|
|
// TestFleetWithVirtualNodes verifies that virtual nodes are included in the fleet list.
|
|
func TestFleetWithVirtualNodes(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Add a real node
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Real Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
|
|
// Add a virtual node
|
|
reg.AddVirtualNode("11:22:33:44:55:66", "Virtual Node", 2.0, 3.0, 4.0)
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{"AA:BB:CC:DD:EE:FF"}
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var nodes []FleetNode
|
|
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(nodes) != 2 {
|
|
t.Fatalf("Expected 2 nodes, got %d", len(nodes))
|
|
}
|
|
|
|
// Verify virtual node is marked correctly
|
|
var virtualNode *FleetNode
|
|
for i := range nodes {
|
|
if nodes[i].MAC == "11:22:33:44:55:66" {
|
|
virtualNode = &nodes[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if virtualNode == nil {
|
|
t.Fatal("Virtual node not found in fleet list")
|
|
}
|
|
|
|
if !virtualNode.Virtual {
|
|
t.Error("Virtual node should have Virtual field set to true")
|
|
}
|
|
|
|
if virtualNode.Label != "Virtual Node" {
|
|
t.Errorf("Virtual node label: expected 'Virtual Node', got '%s'", virtualNode.Label)
|
|
}
|
|
|
|
if virtualNode.Role != "virtual" {
|
|
t.Errorf("Virtual node role: expected 'virtual', got '%s'", virtualNode.Role)
|
|
}
|
|
|
|
if virtualNode.Status != "offline" {
|
|
// Virtual nodes are not connected, so they should be offline
|
|
t.Errorf("Virtual node status: expected 'offline', got '%s'", virtualNode.Status)
|
|
}
|
|
}
|
|
|
|
// TestFleetWithNoNodes verifies the API returns an empty array when no nodes exist.
|
|
func TestFleetWithNoNodes(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{mgr: mgr}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var nodes []FleetNode
|
|
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(nodes) != 0 {
|
|
t.Errorf("Expected 0 nodes, got %d", len(nodes))
|
|
}
|
|
}
|
|
|
|
// TestFleetNodeStatusOffline verifies that offline nodes are correctly identified.
|
|
func TestFleetNodeStatusOffline(t *testing.T) {
|
|
reg := newTestRegistry(t)
|
|
|
|
// Add a node but don't include it in connected list
|
|
reg.UpsertNode("AA:BB:CC:DD:EE:FF", "1.2.0", "ESP32-S3")
|
|
reg.SetNodeLabel("AA:BB:CC:DD:EE:FF", "Offline Node")
|
|
reg.SetNodeRole("AA:BB:CC:DD:EE:FF", "rx")
|
|
|
|
mgr := NewManager(reg)
|
|
|
|
h := &Handler{
|
|
mgr: mgr,
|
|
nodeID: &mockNodeIdentifier{
|
|
getConnectedMACs: func() []string {
|
|
return []string{} // No connected nodes
|
|
},
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/fleet", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.listFleet(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("listFleet() status = %v, want %v", w.Code, http.StatusOK)
|
|
}
|
|
|
|
var nodes []FleetNode
|
|
if err := json.NewDecoder(w.Body).Decode(&nodes); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("Expected 1 node, got %d", len(nodes))
|
|
}
|
|
|
|
if nodes[0].Status != "offline" {
|
|
t.Errorf("Expected status 'offline', got '%s'", nodes[0].Status)
|
|
}
|
|
}
|