Pulse/internal/api/config_node_handlers_additional_test.go
2026-03-18 16:06:30 +00:00

267 lines
8.7 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// TestHandleGetNodes_MockMode_UsesReadState verifies that the mock-mode branch
// of handleGetNodes uses ReadState typed accessors instead of GetState().
func TestHandleGetNodes_MockMode_UsesReadState(t *testing.T) {
mock.SetEnabled(true)
t.Cleanup(func() { mock.SetEnabled(false) })
// Create a monitor with a resource store populated via ReadState.
monitor := &monitoring.Monitor{}
state := models.NewState()
state.Nodes = []models.Node{
{ID: "node/pve1", Name: "pve1", Status: "online", Instance: "mock-cluster", Host: "192.168.0.100:8006"},
{ID: "node/pve2", Name: "pve2", Status: "online", Instance: "mock-cluster", Host: "192.168.0.101:8006"},
{ID: "node/standalone", Name: "standalone", Status: "online", Instance: "standalone-inst", Host: "192.168.0.150:8006"},
}
state.PBSInstances = []models.PBSInstance{
{ID: "pbs-main", Name: "pbs-main", Host: "192.168.0.10:8007", Status: "online"},
}
state.PMGInstances = []models.PMGInstance{
{ID: "pmg-main", Name: "pmg-main", Host: "192.168.0.20:8006", Status: "online"},
}
setUnexportedField(t, monitor, "state", state)
syncTestResourceStore(t, monitor, state)
h := &ConfigHandlers{
defaultMonitor: monitor,
}
req := httptest.NewRequest(http.MethodGet, "/api/config/nodes", nil)
rec := httptest.NewRecorder()
h.handleGetNodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var nodes []NodeResponse
if err := json.NewDecoder(rec.Body).Decode(&nodes); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Expect: 1 cluster entry + 1 standalone + 1 PBS + 1 PMG = 4
if len(nodes) != 4 {
t.Fatalf("expected 4 node responses, got %d: %+v", len(nodes), nodes)
}
// Verify cluster entry
cluster := nodes[0]
if cluster.Type != "pve" || !cluster.IsCluster {
t.Errorf("expected first entry to be PVE cluster, got type=%q isCluster=%v", cluster.Type, cluster.IsCluster)
}
if len(cluster.ClusterEndpoints) != 2 {
t.Errorf("expected 2 cluster endpoints, got %d", len(cluster.ClusterEndpoints))
}
// Verify standalone
standalone := nodes[1]
if standalone.Type != "pve" || standalone.IsCluster {
t.Errorf("expected standalone PVE, got type=%q isCluster=%v", standalone.Type, standalone.IsCluster)
}
if standalone.Name != "standalone" {
t.Errorf("expected standalone name, got %q", standalone.Name)
}
// Verify PBS
pbsEntry := nodes[2]
if pbsEntry.Type != "pbs" {
t.Errorf("expected PBS type, got %q", pbsEntry.Type)
}
if pbsEntry.Name != "pbs-main" {
t.Errorf("expected pbs-main name, got %q", pbsEntry.Name)
}
if pbsEntry.ExcludeDatastores == nil {
t.Error("expected PBS excludeDatastores to normalize to an empty slice")
}
// Verify PMG
pmgEntry := nodes[3]
if pmgEntry.Type != "pmg" {
t.Errorf("expected PMG type, got %q", pmgEntry.Type)
}
if pmgEntry.Name != "pmg-main" {
t.Errorf("expected pmg-main name, got %q", pmgEntry.Name)
}
if pmgEntry.ClusterEndpoints == nil {
t.Error("expected PMG clusterEndpoints to normalize to an empty slice")
}
}
// TestHandleGetNodes_MockMode_NilReadState verifies that when ReadState is nil
// (no resource store wired), the handler returns an empty node list without
// calling GetState(). This replaced the former GetState() fallback test.
func TestHandleGetNodes_MockMode_NilReadState(t *testing.T) {
mock.SetEnabled(true)
t.Cleanup(func() { mock.SetEnabled(false) })
// Monitor with state but no resource store — GetUnifiedReadState() returns nil.
monitor := &monitoring.Monitor{}
state := models.NewState()
state.Nodes = []models.Node{
{ID: "node/test", Name: "test-node", Status: "online", Instance: "standalone"},
}
setUnexportedField(t, monitor, "state", state)
h := &ConfigHandlers{
defaultMonitor: monitor,
}
req := httptest.NewRequest(http.MethodGet, "/api/config/nodes", nil)
rec := httptest.NewRecorder()
h.handleGetNodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var nodes []NodeResponse
if err := json.NewDecoder(rec.Body).Decode(&nodes); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// With no ReadState, handler returns empty list (no GetState fallback).
if len(nodes) != 0 {
t.Errorf("expected 0 nodes when ReadState is nil, got %d", len(nodes))
}
}
// Ensure ConfigHandlers.getMonitor falls back to defaultMonitor when no
// multi-tenant monitor is configured (used by mock-mode tests above).
func TestConfigHandlers_getMonitor_Legacy(t *testing.T) {
monitor := &monitoring.Monitor{}
h := &ConfigHandlers{defaultMonitor: monitor}
got := h.getMonitor(context.Background())
if got != monitor {
t.Fatal("expected getMonitor to return legacy monitor")
}
}
// TestHandleGetNodes_MockMode_ReadStateTakesPriority seeds both the legacy state
// and the ReadState adapter with different names. Since the handler uses ReadState
// exclusively (no GetState fallback), only ReadState names should appear.
func TestHandleGetNodes_MockMode_ReadStateTakesPriority(t *testing.T) {
mock.SetEnabled(true)
t.Cleanup(func() { mock.SetEnabled(false) })
monitor := &monitoring.Monitor{}
// Legacy state: node named "legacy-node"
legacyState := models.NewState()
legacyState.Nodes = []models.Node{
{ID: "node/legacy", Name: "legacy-node", Status: "online", Instance: "standalone"},
}
setUnexportedField(t, monitor, "state", legacyState)
// ReadState adapter: node named "readstate-node" (different from legacy)
readStateSnapshot := models.StateSnapshot{
Nodes: []models.Node{
{ID: "node/rs", Name: "readstate-node", Status: "online", Instance: "standalone"},
},
}
adapter := unifiedresources.NewMonitorAdapter(nil)
adapter.PopulateFromSnapshot(readStateSnapshot)
setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter))
h := &ConfigHandlers{
defaultMonitor: monitor,
}
req := httptest.NewRequest(http.MethodGet, "/api/config/nodes", nil)
rec := httptest.NewRecorder()
h.handleGetNodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var nodes []NodeResponse
if err := json.NewDecoder(rec.Body).Decode(&nodes); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Must find "readstate-node", NOT "legacy-node"
foundReadState := false
foundLegacy := false
for _, n := range nodes {
if n.Name == "readstate-node" {
foundReadState = true
}
if n.Name == "legacy-node" {
foundLegacy = true
}
}
if !foundReadState {
t.Error("expected ReadState node name 'readstate-node' in response — ReadState branch not taken")
}
if foundLegacy {
t.Error("found legacy node name 'legacy-node' in response — handler used GetState() instead of ReadState")
}
}
// Verify the MonitorAdapter satisfies ReadState when populated with PBS/PMG data.
func TestMonitorAdapter_PBSPMGViews(t *testing.T) {
adapter := unifiedresources.NewMonitorAdapter(nil)
snapshot := models.StateSnapshot{
PBSInstances: []models.PBSInstance{
{ID: "pbs-1", Name: "backup-server", Host: "10.0.0.1:8007", Status: "online"},
},
PMGInstances: []models.PMGInstance{
{ID: "pmg-1", Name: "mail-gateway", Host: "10.0.0.2:8006", Status: "online"},
},
}
adapter.PopulateFromSnapshot(snapshot)
pbsList := adapter.PBSInstances()
if len(pbsList) != 1 {
t.Fatalf("expected 1 PBS instance, got %d", len(pbsList))
}
if pbsList[0].Name() != "backup-server" {
t.Errorf("expected PBS name 'backup-server', got %q", pbsList[0].Name())
}
// Hostname is extracted without port
if pbsList[0].Hostname() != "10.0.0.1" {
t.Errorf("expected PBS hostname '10.0.0.1', got %q", pbsList[0].Hostname())
}
pmgList := adapter.PMGInstances()
if len(pmgList) != 1 {
t.Fatalf("expected 1 PMG instance, got %d", len(pmgList))
}
if pmgList[0].Name() != "mail-gateway" {
t.Errorf("expected PMG name 'mail-gateway', got %q", pmgList[0].Name())
}
if pmgList[0].Hostname() != "10.0.0.2" {
t.Errorf("expected PMG hostname '10.0.0.2', got %q", pmgList[0].Hostname())
}
}
func TestNodeResponse_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyNodeResponse())
if err != nil {
t.Fatalf("marshal empty node response: %v", err)
}
if !strings.Contains(string(payload), `"excludeDatastores":[]`) {
t.Fatalf("expected empty node response to retain excludeDatastores array, got %s", payload)
}
if !strings.Contains(string(payload), `"clusterEndpoints":[]`) {
t.Fatalf("expected empty node response to retain clusterEndpoints array, got %s", payload)
}
}