Add report engine and cluster client coverage

This commit is contained in:
rcourtman
2026-02-05 11:36:54 +00:00
parent f7f3cd3297
commit 5fe467c4e5
6 changed files with 937 additions and 0 deletions
@@ -0,0 +1,95 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestClusterClient_GetCephDF(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/cluster/ceph/df" {
fmt.Fprint(w, `{"data":{"data":{"stats":{"total_bytes":100,"total_used_bytes":20}}}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
df, err := cc.GetCephDF(context.Background())
if err != nil {
t.Fatalf("GetCephDF failed: %v", err)
}
if df == nil || df.Data.Stats.TotalBytes != 100 {
t.Fatalf("unexpected ceph df: %+v", df)
}
}
func TestClusterClient_GetReplicationStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api2/json/nodes":
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
case "/api2/json/cluster/replication":
fmt.Fprint(w, `{"data":[{"id":"job1","guest":"vm/100","source":"node1"}]}`)
case "/api2/json/nodes/node1/replication/job1/status":
fmt.Fprint(w, `{"data":{"last_sync":1700000000,"duration":10,"fail_count":0,"state":"ok"}}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
jobs, err := cc.GetReplicationStatus(context.Background())
if err != nil {
t.Fatalf("GetReplicationStatus failed: %v", err)
}
if len(jobs) != 1 || jobs[0].ID != "job1" {
t.Fatalf("unexpected jobs: %+v", jobs)
}
if jobs[0].LastSyncUnix == 0 || jobs[0].State == "" {
t.Fatalf("expected enriched status fields, got %+v", jobs[0])
}
}
func TestClusterClient_IsClusterMemberFalseOnError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/cluster/status" {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, `{"data":null}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
member, err := cc.IsClusterMember(context.Background())
if err != nil {
t.Fatalf("IsClusterMember returned error: %v", err)
}
if member {
t.Fatal("expected IsClusterMember to return false when status fetch fails")
}
}
@@ -0,0 +1,34 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestClusterClient_GetContainerInterfaces_ErrorStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api2/json/nodes" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/lxc/101/interfaces" {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "boom")
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
_, err := cc.GetContainerInterfaces(context.Background(), "node1", 101)
if err == nil {
t.Fatal("expected error for non-200 response")
}
}
@@ -0,0 +1,115 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestClusterClient_GetZFSPoolsWithDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api2/json/nodes":
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
case "/api2/json/nodes/node1/disks/zfs":
fmt.Fprint(w, `{"data":[{"name":"rpool","health":"ONLINE"}]}`)
case "/api2/json/nodes/node1/disks/zfs/rpool":
fmt.Fprint(w, `{"data":{"name":"rpool","state":"ONLINE","status":"ok","scan":"none","errors":"0"}}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
pools, err := cc.GetZFSPoolsWithDetails(context.Background(), "node1")
if err != nil {
t.Fatalf("GetZFSPoolsWithDetails failed: %v", err)
}
if len(pools) != 1 || pools[0].Name != "rpool" {
t.Fatalf("unexpected pools: %+v", pools)
}
if pools[0].State != "ONLINE" {
t.Fatalf("expected state ONLINE, got %q", pools[0].State)
}
}
func TestClusterClient_IsQuorate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/cluster/status" {
fmt.Fprint(w, `{"data":[{"type":"cluster","name":"pve","quorate":1}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
quorate, err := cc.IsQuorate(context.Background())
if err != nil {
t.Fatalf("IsQuorate failed: %v", err)
}
if !quorate {
t.Fatal("expected quorate true")
}
}
func TestClusterClient_IsQuorateStandalone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/cluster/status" {
fmt.Fprint(w, `{"data":[{"type":"node","name":"node1"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
quorate, err := cc.IsQuorate(context.Background())
if err != nil {
t.Fatalf("IsQuorate failed: %v", err)
}
if !quorate {
t.Fatal("expected quorate true for standalone node")
}
}
func TestClusterClient_GetDisks_NoHealthyNodes(t *testing.T) {
cc := &ClusterClient{
name: "test",
endpoints: []string{"node1", "node2"},
clients: make(map[string]*Client),
nodeHealth: map[string]bool{"node1": false, "node2": false},
lastError: make(map[string]string),
lastHealthCheck: map[string]time.Time{"node1": time.Now(), "node2": time.Now()},
rateLimitUntil: make(map[string]time.Time),
}
disks, err := cc.GetDisks(context.Background(), "node1")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(disks) != 0 {
t.Fatalf("expected empty disks on no healthy nodes, got %+v", disks)
}
}
@@ -0,0 +1,399 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClusterClient_GetNodeStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/status" {
fmt.Fprint(w, `{"data":{"cpu":0.25,"uptime":123}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
status, err := cc.GetNodeStatus(context.Background(), "node1")
if err != nil {
t.Fatalf("GetNodeStatus failed: %v", err)
}
if status == nil || status.Uptime != 123 {
t.Fatalf("unexpected status: %+v", status)
}
}
func TestClusterClient_GetAllStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/storage" {
fmt.Fprint(w, `{"data":[{"storage":"local"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
storage, err := cc.GetAllStorage(context.Background())
if err != nil {
t.Fatalf("GetAllStorage failed: %v", err)
}
if len(storage) != 1 || storage[0].Storage != "local" {
t.Fatalf("unexpected storage result: %+v", storage)
}
}
func TestClusterClient_GetVMStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/qemu/100/status/current" {
fmt.Fprint(w, `{"data":{"status":"running","cpu":0.5,"uptime":10,"agent":1}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
status, err := cc.GetVMStatus(context.Background(), "node1", 100)
if err != nil {
t.Fatalf("GetVMStatus failed: %v", err)
}
if status == nil || status.Status != "running" {
t.Fatalf("unexpected VM status: %+v", status)
}
}
func TestClusterClient_GetContainerStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/lxc/101/status/current" {
fmt.Fprint(w, `{"data":{"status":"running"}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
status, err := cc.GetContainerStatus(context.Background(), "node1", 101)
if err != nil {
t.Fatalf("GetContainerStatus failed: %v", err)
}
if status == nil || status.Status != "running" {
t.Fatalf("unexpected container status: %+v", status)
}
}
func TestClusterClient_GetVMConfig(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/qemu/100/config" {
fmt.Fprint(w, `{"data":{"name":"vm-100"}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
config, err := cc.GetVMConfig(context.Background(), "node1", 100)
if err != nil {
t.Fatalf("GetVMConfig failed: %v", err)
}
if config["name"] != "vm-100" {
t.Fatalf("unexpected VM config: %+v", config)
}
}
func TestClusterClient_GetVMAgentInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/get-osinfo" {
fmt.Fprint(w, `{"data":{"name":"debian"}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
info, err := cc.GetVMAgentInfo(context.Background(), "node1", 100)
if err != nil {
t.Fatalf("GetVMAgentInfo failed: %v", err)
}
if info["name"] != "debian" {
t.Fatalf("unexpected agent info: %+v", info)
}
}
func TestClusterClient_GetVMAgentVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/info" {
fmt.Fprint(w, `{"data":{"result":{"version":"1.2.3"}}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
version, err := cc.GetVMAgentVersion(context.Background(), "node1", 100)
if err != nil {
t.Fatalf("GetVMAgentVersion failed: %v", err)
}
if version != "1.2.3" {
t.Fatalf("unexpected version: %s", version)
}
}
func TestClusterClient_GetVMNetworkInterfaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces" {
fmt.Fprint(w, `{"data":{"result":[{"name":"eth0"}]}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
ifaces, err := cc.GetVMNetworkInterfaces(context.Background(), "node1", 100)
if err != nil {
t.Fatalf("GetVMNetworkInterfaces failed: %v", err)
}
if len(ifaces) != 1 || ifaces[0].Name != "eth0" {
t.Fatalf("unexpected interfaces: %+v", ifaces)
}
}
func TestClusterClient_GetContainerInterfaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/lxc/101/interfaces" {
fmt.Fprint(w, `{"data":[{"name":"eth0","ip":"10.0.0.2"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
ifaces, err := cc.GetContainerInterfaces(context.Background(), "node1", 101)
if err != nil {
t.Fatalf("GetContainerInterfaces failed: %v", err)
}
if len(ifaces) != 1 || ifaces[0].Name != "eth0" {
t.Fatalf("unexpected interfaces: %+v", ifaces)
}
}
func TestClusterClient_GetClusterResources(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if strings.HasPrefix(r.URL.Path, "/api2/json/cluster/resources") {
fmt.Fprint(w, `{"data":[{"id":"qemu/100","type":"qemu","node":"node1"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
resources, err := cc.GetClusterResources(context.Background(), "qemu")
if err != nil {
t.Fatalf("GetClusterResources failed: %v", err)
}
if len(resources) != 1 || resources[0].ID != "qemu/100" {
t.Fatalf("unexpected resources: %+v", resources)
}
}
func TestClusterClient_GetClusterStatusAndIsClusterMember(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/cluster/status" {
fmt.Fprint(w, `{"data":[{"type":"cluster","name":"pve"},{"type":"node","name":"node1"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
status, err := cc.GetClusterStatus(context.Background())
if err != nil {
t.Fatalf("GetClusterStatus failed: %v", err)
}
if len(status) != 2 {
t.Fatalf("unexpected cluster status: %+v", status)
}
member, err := cc.IsClusterMember(context.Background())
if err != nil {
t.Fatalf("IsClusterMember failed: %v", err)
}
if !member {
t.Fatal("expected cluster membership to be true")
}
}
func TestClusterClient_GetZFSPoolStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/disks/zfs" {
fmt.Fprint(w, `{"data":[{"name":"rpool","health":"ONLINE"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
pools, err := cc.GetZFSPoolStatus(context.Background(), "node1")
if err != nil {
t.Fatalf("GetZFSPoolStatus failed: %v", err)
}
if len(pools) != 1 || pools[0].Name != "rpool" {
t.Fatalf("unexpected pools: %+v", pools)
}
}
func TestClusterClient_GetDisks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/disks/list" {
fmt.Fprint(w, `{"data":[{"devpath":"/dev/sda","model":"Samsung","wearout":"N/A"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
disks, err := cc.GetDisks(context.Background(), "node1")
if err != nil {
t.Fatalf("GetDisks failed: %v", err)
}
if len(disks) != 1 || disks[0].DevPath != "/dev/sda" {
t.Fatalf("unexpected disks: %+v", disks)
}
if disks[0].Wearout != wearoutUnknown {
t.Fatalf("expected wearout unknown, got %d", disks[0].Wearout)
}
}
func TestClusterClient_GetNodePendingUpdates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
if r.URL.Path == "/api2/json/nodes/node1/apt/update" {
fmt.Fprint(w, `{"data":[{"Package":"vim","Version":"2"}]}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"}
cc := NewClusterClient("test", cfg, []string{server.URL}, nil)
updates, err := cc.GetNodePendingUpdates(context.Background(), "node1")
if err != nil {
t.Fatalf("GetNodePendingUpdates failed: %v", err)
}
if len(updates) != 1 || updates[0].Package != "vim" {
t.Fatalf("unexpected updates: %+v", updates)
}
}
+108
View File
@@ -0,0 +1,108 @@
package proxmox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestGetHealthyClientSingleEndpointFallback(t *testing.T) {
endpoint := "http://example.invalid"
cc := &ClusterClient{
name: "single",
endpoints: []string{endpoint},
clients: map[string]*Client{endpoint: {}},
nodeHealth: map[string]bool{endpoint: false},
lastError: make(map[string]string),
lastHealthCheck: make(map[string]time.Time),
rateLimitUntil: make(map[string]time.Time),
}
client, err := cc.getHealthyClient(context.Background())
if err != nil {
t.Fatalf("getHealthyClient failed: %v", err)
}
if client == nil {
t.Fatal("expected client, got nil")
}
if !cc.nodeHealth[endpoint] {
t.Fatal("expected endpoint to be marked healthy after fallback")
}
}
func TestExecuteWithFailoverUsesRecoveredEndpoint(t *testing.T) {
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`)
return
}
fmt.Fprint(w, `{"data":{}}`)
}))
defer server1.Close()
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api2/json/nodes" {
fmt.Fprint(w, `{"data":[{"node":"node2","status":"online"}]}`)
return
}
fmt.Fprint(w, `{"data":{}}`)
}))
defer server2.Close()
cfg := ClientConfig{
Host: server1.URL,
TokenName: "u@p!t",
TokenValue: "v",
VerifySSL: false,
Timeout: 2 * time.Second,
}
cc := NewClusterClient("test", cfg, []string{server1.URL, server2.URL}, nil)
// Force deterministic selection: only server1 healthy, server2 unhealthy.
cc.mu.Lock()
cc.nodeHealth[server1.URL] = true
cc.nodeHealth[server2.URL] = false
cc.lastHealthCheck[server2.URL] = time.Now().Add(-11 * time.Second)
cc.mu.Unlock()
usedEndpoints := make([]string, 0, 2)
err := cc.executeWithFailover(context.Background(), func(c *Client) error {
endpoint := ""
cc.mu.RLock()
for ep, client := range cc.clients {
if client == c {
endpoint = ep
break
}
}
cc.mu.RUnlock()
if endpoint == "" {
return fmt.Errorf("failed to resolve endpoint for client")
}
usedEndpoints = append(usedEndpoints, endpoint)
if endpoint == server1.URL {
return fmt.Errorf("boom")
}
return nil
})
if err != nil {
t.Fatalf("executeWithFailover failed: %v", err)
}
if len(usedEndpoints) < 2 {
t.Fatalf("expected failover to second endpoint, used: %v", usedEndpoints)
}
health := cc.GetHealthStatus()
if health[server1.URL] {
t.Fatal("expected server1 to be unhealthy after failure")
}
if !health[server2.URL] {
t.Fatal("expected server2 to be healthy after recovery")
}
}
+186
View File
@@ -0,0 +1,186 @@
package reporting
import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
func TestReportEngineGenerateMissingStore(t *testing.T) {
engine := NewReportEngine(EngineConfig{})
_, _, err := engine.Generate(MetricReportRequest{
ResourceType: "node",
ResourceID: "node-1",
Start: time.Now().Add(-1 * time.Hour),
End: time.Now(),
Format: FormatCSV,
})
if err == nil {
t.Fatal("expected error when metrics store is nil")
}
if !strings.Contains(err.Error(), "metrics store not initialized") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReportEngineGenerateUnsupportedFormat(t *testing.T) {
store := newTestMetricsStore(t)
defer store.Close()
engine := NewReportEngine(EngineConfig{MetricsStore: store})
_, _, err := engine.Generate(MetricReportRequest{
ResourceType: "node",
ResourceID: "node-1",
Start: time.Now().Add(-1 * time.Hour),
End: time.Now(),
Format: ReportFormat("xls"),
})
if err == nil {
t.Fatal("expected unsupported format error")
}
if !strings.Contains(err.Error(), "unsupported format") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReportEngineQueryMetricsSpecificMetric(t *testing.T) {
store := newTestMetricsStore(t)
defer store.Close()
start := time.Now().Add(-30 * time.Minute)
points := []metrics.WriteMetric{
{ResourceType: "node", ResourceID: "node-1", MetricType: "cpu", Value: 10, Timestamp: start, Tier: metrics.TierRaw},
{ResourceType: "node", ResourceID: "node-1", MetricType: "cpu", Value: 30, Timestamp: start.Add(5 * time.Minute), Tier: metrics.TierRaw},
{ResourceType: "node", ResourceID: "node-1", MetricType: "memory", Value: 50, Timestamp: start, Tier: metrics.TierRaw},
}
store.WriteBatchSync(points)
engine := NewReportEngine(EngineConfig{MetricsStore: store})
data, err := engine.queryMetrics(MetricReportRequest{
ResourceType: "node",
ResourceID: "node-1",
MetricType: "cpu",
Start: start.Add(-1 * time.Minute),
End: start.Add(10 * time.Minute),
})
if err != nil {
t.Fatalf("queryMetrics failed: %v", err)
}
if data.Title != "node Report: node-1" {
t.Fatalf("expected default title, got %q", data.Title)
}
if len(data.Metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(data.Metrics))
}
if _, ok := data.Metrics["cpu"]; !ok {
t.Fatal("expected cpu metric data to be present")
}
if _, ok := data.Metrics["memory"]; ok {
t.Fatal("expected memory metric to be filtered out")
}
stats, ok := data.Summary.ByMetric["cpu"]
if !ok {
t.Fatal("expected cpu summary stats")
}
if stats.Count != 2 {
t.Fatalf("expected cpu count 2, got %d", stats.Count)
}
if stats.Min != 10 || stats.Max != 30 {
t.Fatalf("unexpected cpu min/max: %+v", stats)
}
if stats.Avg != 20 {
t.Fatalf("expected cpu avg 20, got %v", stats.Avg)
}
if stats.Current != 30 {
t.Fatalf("expected cpu current 30, got %v", stats.Current)
}
}
func TestReportEngineQueryMetricsNoData(t *testing.T) {
store := newTestMetricsStore(t)
defer store.Close()
engine := NewReportEngine(EngineConfig{MetricsStore: store})
start := time.Now().Add(-1 * time.Hour)
data, err := engine.queryMetrics(MetricReportRequest{
ResourceType: "node",
ResourceID: "missing-node",
Start: start,
End: time.Now(),
})
if err != nil {
t.Fatalf("queryMetrics failed: %v", err)
}
if data.TotalPoints != 0 {
t.Fatalf("expected zero points, got %d", data.TotalPoints)
}
if len(data.Metrics) != 0 {
t.Fatalf("expected no metrics, got %d", len(data.Metrics))
}
if data.Title != "node Report: missing-node" {
t.Fatalf("expected default title, got %q", data.Title)
}
}
func TestReportEngineGenerateMultiAggregatesData(t *testing.T) {
store := newTestMetricsStore(t)
defer store.Close()
base := time.Now().Add(-15 * time.Minute)
store.WriteBatchSync([]metrics.WriteMetric{
{ResourceType: "node", ResourceID: "node-1", MetricType: "cpu", Value: 10, Timestamp: base, Tier: metrics.TierRaw},
{ResourceType: "node", ResourceID: "node-2", MetricType: "cpu", Value: 20, Timestamp: base.Add(2 * time.Minute), Tier: metrics.TierRaw},
})
engine := NewReportEngine(EngineConfig{MetricsStore: store})
data, contentType, err := engine.GenerateMulti(MultiReportRequest{
Resources: []MetricReportRequest{
{ResourceType: "node", ResourceID: "node-1"},
{ResourceType: "node", ResourceID: "node-2"},
},
MetricType: "cpu",
Start: base.Add(-1 * time.Minute),
End: base.Add(5 * time.Minute),
Format: FormatCSV,
})
if err != nil {
t.Fatalf("GenerateMulti failed: %v", err)
}
if contentType != "text/csv" {
t.Fatalf("expected text/csv, got %s", contentType)
}
csv := string(data)
if !strings.Contains(csv, "# Pulse Multi-Resource Metrics Report") {
t.Fatal("missing multi-resource header")
}
if !strings.Contains(csv, "node-1") || !strings.Contains(csv, "node-2") {
t.Fatal("expected both resource IDs in output")
}
}
func newTestMetricsStore(t *testing.T) *metrics.Store {
t.Helper()
dir := t.TempDir()
cfg := metrics.DefaultConfig(dir)
cfg.DBPath = filepath.Join(dir, "metrics.db")
cfg.WriteBufferSize = 1
cfg.FlushInterval = 50 * time.Millisecond
store, err := metrics.NewStore(cfg)
if err != nil {
t.Fatalf("failed to create metrics store: %v", err)
}
return store
}