Files
pulse/internal/models/converters_test.go
T
rcourtman f48c806718 Preserve observed Docker storage evidence
Missing block I/O and container image sizes could become false evidence
for diagnosis. Preserve per-direction counter presence and measured zero
through collection, resource conversion and browser rendering. Separate
new observed history from ambiguous retained disk series without deleting
old rows or changing public metric names.

Keep partial host rates distinct and persist a newly enabled Disk I/O
column across the first preference reload.
2026-09-06 12:45:40 +01:00

1475 lines
41 KiB
Go

package models
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestHostFrontendDoesNotExposeOperationReceiptProtocol(t *testing.T) {
host := Host{ID: "agent", OperationReceiptVersion: 1}
encoded, err := json.Marshal(host.ToFrontend())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "operationReceiptVersion") {
t.Fatalf("frontend payload exposed protocol: %s", encoded)
}
}
func TestZeroIfNegative(t *testing.T) {
tests := []struct {
name string
input int64
expected int64
}{
{"positive value", 100, 100},
{"zero", 0, 0},
{"negative value", -50, 0},
{"large negative", -999999, 0},
{"large positive", 999999, 999999},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := zeroIfNegative(tc.input)
if result != tc.expected {
t.Errorf("zeroIfNegative(%d) = %d, want %d", tc.input, result, tc.expected)
}
})
}
}
func TestCopyStringFloatMap(t *testing.T) {
tests := []struct {
name string
input map[string]float64
nilCheck bool
}{
{
name: "nil map",
input: nil,
nilCheck: true,
},
{
name: "empty map",
input: map[string]float64{},
nilCheck: true,
},
{
name: "populated map",
input: map[string]float64{
"cpu": 45.0,
"gpu": 65.0,
"nvme": 40.0,
},
nilCheck: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := copyStringFloatMap(tc.input)
if tc.nilCheck {
if result != nil {
t.Errorf("copyStringFloatMap(%v) = %v, want nil", tc.input, result)
}
return
}
// Verify copy is separate from original
if len(result) != len(tc.input) {
t.Errorf("copyStringFloatMap length = %d, want %d", len(result), len(tc.input))
}
for k, v := range tc.input {
if result[k] != v {
t.Errorf("result[%s] = %f, want %f", k, result[k], v)
}
}
// Verify it's a copy, not the same reference
if len(tc.input) > 0 {
for k := range result {
result[k] = 999.0
break
}
// Original should be unchanged
for k, v := range tc.input {
if v == 999.0 && tc.input[k] != 999.0 {
t.Error("copyStringFloatMap should create a copy, not reference")
}
break
}
}
})
}
}
func TestNodeToFrontend(t *testing.T) {
now := time.Now()
node := Node{
ID: "node-1",
Name: "pve1",
DisplayName: "Proxmox Node 1",
Instance: "default",
Host: "192.168.1.100",
Status: "online",
Type: "node",
CPU: 0.25,
Memory: Memory{
Total: 16000000000,
Used: 8000000000,
},
Disk: Disk{
Total: 500000000000,
Used: 250000000000,
},
Uptime: 86400,
LoadAverage: []float64{0.5, 0.7, 0.9},
KernelVersion: "5.15.0",
PVEVersion: "7.4-3",
LastSeen: now,
ConnectionHealth: "connected",
IsClusterMember: true,
ClusterName: "pve-cluster",
LinkedAgentID: "agent-123",
}
frontend := node.ToFrontend()
if frontend.ID != node.ID {
t.Errorf("ID = %q, want %q", frontend.ID, node.ID)
}
if frontend.Node != node.Name {
t.Errorf("Node = %q, want %q", frontend.Node, node.Name)
}
if frontend.DisplayName != node.DisplayName {
t.Errorf("DisplayName = %q, want %q", frontend.DisplayName, node.DisplayName)
}
if frontend.Status != node.Status {
t.Errorf("Status = %q, want %q", frontend.Status, node.Status)
}
if frontend.CPU != node.CPU {
t.Errorf("CPU = %f, want %f", frontend.CPU, node.CPU)
}
if frontend.Mem != node.Memory.Used {
t.Errorf("Mem = %d, want %d", frontend.Mem, node.Memory.Used)
}
if frontend.MaxMem != node.Memory.Total {
t.Errorf("MaxMem = %d, want %d", frontend.MaxMem, node.Memory.Total)
}
if frontend.Memory == nil {
t.Error("Memory should not be nil when Total > 0")
}
if frontend.Disk == nil {
t.Error("Disk should not be nil when Total > 0")
}
if frontend.LastSeen != now.Unix()*1000 {
t.Errorf("LastSeen = %d, want %d", frontend.LastSeen, now.Unix()*1000)
}
if len(frontend.LoadAverage) != 3 {
t.Errorf("LoadAverage length = %d, want 3", len(frontend.LoadAverage))
}
if frontend.LinkedAgentID != node.LinkedAgentID {
t.Errorf("LinkedAgentID = %q, want %q", frontend.LinkedAgentID, node.LinkedAgentID)
}
}
func TestNodeToFrontend_EmptyDisplayName(t *testing.T) {
node := Node{
Name: "pve1",
DisplayName: "",
LastSeen: time.Now(),
}
frontend := node.ToFrontend()
// Should fall back to Name
if frontend.DisplayName != node.Name {
t.Errorf("DisplayName = %q, want %q (should fallback to Name)", frontend.DisplayName, node.Name)
}
}
func TestVMToFrontend(t *testing.T) {
now := time.Now()
lastBackup := now.Add(-24 * time.Hour)
vm := VM{
ID: "vm-100",
VMID: 100,
Name: "test-vm",
Node: "pve1",
Instance: "default",
Status: "running",
Type: "qemu",
CPU: 0.15,
CPUs: 4,
Memory: Memory{Total: 8000000000, Used: 4000000000},
Disk: Disk{Total: 100000000000, Used: 50000000000},
NetworkIn: 1000000,
NetworkOut: 500000,
DiskRead: 100000,
DiskWrite: 50000,
Uptime: 3600,
Tags: []string{"production", "web"},
LastSeen: now,
LastBackup: lastBackup,
IPAddresses: []string{"192.168.1.50", "10.0.0.50"},
OSName: "Ubuntu",
OSVersion: "22.04",
GuestAgentStatus: "expected-unreachable",
GuestAgentExpected: true,
}
frontend := vm.ToFrontend()
if frontend.ID != vm.ID {
t.Errorf("ID = %q, want %q", frontend.ID, vm.ID)
}
if frontend.VMID != vm.VMID {
t.Errorf("VMID = %d, want %d", frontend.VMID, vm.VMID)
}
if frontend.Name != vm.Name {
t.Errorf("Name = %q, want %q", frontend.Name, vm.Name)
}
if frontend.Status != vm.Status {
t.Errorf("Status = %q, want %q", frontend.Status, vm.Status)
}
if frontend.GuestAgentStatus != vm.GuestAgentStatus {
t.Errorf("GuestAgentStatus = %q, want %q", frontend.GuestAgentStatus, vm.GuestAgentStatus)
}
if frontend.GuestAgentExpected != vm.GuestAgentExpected {
t.Errorf("GuestAgentExpected = %t, want %t", frontend.GuestAgentExpected, vm.GuestAgentExpected)
}
if frontend.CPU != vm.CPU {
t.Errorf("CPU = %f, want %f", frontend.CPU, vm.CPU)
}
if frontend.Tags != "production,web" {
t.Errorf("Tags = %q, want 'production,web'", frontend.Tags)
}
if frontend.LastBackup != lastBackup.Unix()*1000 {
t.Errorf("LastBackup = %d, want %d", frontend.LastBackup, lastBackup.Unix()*1000)
}
if len(frontend.IPAddresses) != 2 {
t.Errorf("IPAddresses length = %d, want 2", len(frontend.IPAddresses))
}
if frontend.OSName != "Ubuntu" {
t.Errorf("OSName = %q, want Ubuntu", frontend.OSName)
}
if frontend.Memory == nil {
t.Error("Memory should not be nil when Total > 0")
}
}
func TestVMToFrontend_NegativeNetworkValues(t *testing.T) {
vm := VM{
NetworkIn: -1,
NetworkOut: -1,
DiskRead: -1,
DiskWrite: -1,
LastSeen: time.Now(),
}
frontend := vm.ToFrontend()
if frontend.NetIn != 0 {
t.Errorf("NetIn = %d, want 0 (should convert negative to 0)", frontend.NetIn)
}
if frontend.NetOut != 0 {
t.Errorf("NetOut = %d, want 0 (should convert negative to 0)", frontend.NetOut)
}
if frontend.DiskRead != 0 {
t.Errorf("DiskRead = %d, want 0 (should convert negative to 0)", frontend.DiskRead)
}
if frontend.DiskWrite != 0 {
t.Errorf("DiskWrite = %d, want 0 (should convert negative to 0)", frontend.DiskWrite)
}
}
func TestConvertResourceToFrontendIncludesDiskIO(t *testing.T) {
read, write := int64(4096), int64(8192)
frontend := ConvertResourceToFrontend(ResourceConvertInput{
ID: "agent-1",
Type: "agent",
Name: "tower",
DisplayName: "tower",
PlatformID: "tower",
PlatformType: "agent",
SourceType: "agent",
Status: "online",
LastSeenUnix: time.Now().UnixMilli(),
DiskReadRate: &read,
DiskWriteRate: &write,
})
if frontend.DiskIO == nil {
t.Fatal("expected disk I/O rates to be present")
}
if frontend.DiskIO.ReadRate == nil || frontend.DiskIO.WriteRate == nil || *frontend.DiskIO.ReadRate != 4096 || *frontend.DiskIO.WriteRate != 8192 {
t.Fatalf("unexpected disk I/O rates: %+v", frontend.DiskIO)
}
}
func TestContainerToFrontend(t *testing.T) {
now := time.Now()
t.Run("lxc", func(t *testing.T) {
container := Container{
ID: "ct-101",
VMID: 101,
Name: "test-ct",
Node: "pve1",
Status: "running",
Type: "lxc",
CPU: 0.10,
CPUs: 2,
Memory: Memory{Total: 4000000000, Used: 2000000000},
Disk: Disk{Total: 50000000000, Used: 25000000000},
NetworkIn: 500000,
NetworkOut: 250000,
Uptime: 7200,
Tags: []string{"dev", "database"},
LastSeen: now,
IPAddresses: []string{"192.168.1.51"},
}
frontend := container.ToFrontend()
if frontend.ID != container.ID {
t.Errorf("ID = %q, want %q", frontend.ID, container.ID)
}
if frontend.VMID != container.VMID {
t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID)
}
if frontend.Type != container.Type {
t.Errorf("Type = %q, want %q", frontend.Type, container.Type)
}
if frontend.IsOCI {
t.Errorf("IsOCI = %v, want false", frontend.IsOCI)
}
if frontend.Tags != "dev,database" {
t.Errorf("Tags = %q, want 'dev,database'", frontend.Tags)
}
if frontend.Memory == nil {
t.Error("Memory should not be nil when Total > 0")
}
})
t.Run("oci", func(t *testing.T) {
container := Container{
ID: "ct-300",
VMID: 300,
Name: "oci-alpine",
Node: "pve1",
Status: "running",
Type: "lxc",
IsOCI: true,
OSTemplate: "oci:docker.io/library/alpine:latest",
CPU: 0.05,
CPUs: 2,
Memory: Memory{Total: 1000000000, Used: 100000000},
Disk: Disk{Total: 10000000000, Used: 1000000000},
LastSeen: now,
}
frontend := container.ToFrontend()
if frontend.Type != "oci" {
t.Errorf("Type = %q, want %q", frontend.Type, "oci")
}
if !frontend.IsOCI {
t.Errorf("IsOCI = %v, want true", frontend.IsOCI)
}
if frontend.OSTemplate != container.OSTemplate {
t.Errorf("OSTemplate = %q, want %q", frontend.OSTemplate, container.OSTemplate)
}
})
t.Run("oci-derived-from-type", func(t *testing.T) {
container := Container{
ID: "ct-301",
VMID: 301,
Name: "oci-from-type",
Node: "pve1",
Status: "running",
Type: "oci",
IsOCI: false,
LastSeen: now,
}
frontend := container.ToFrontend()
if frontend.Type != "oci" {
t.Errorf("Type = %q, want %q", frontend.Type, "oci")
}
if !frontend.IsOCI {
t.Errorf("IsOCI = %v, want true", frontend.IsOCI)
}
})
}
func TestDockerContainerToFrontend(t *testing.T) {
now := time.Now()
startedAt := now.Add(-1 * time.Hour)
oomKilled := false
container := DockerContainer{
ID: "abc123def456",
Name: "nginx-web",
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Health: "healthy",
CPUPercent: 15.5,
MemoryUsage: 512000000,
MemoryLimit: 1024000000,
MemoryPercent: 50.0,
UptimeSeconds: 3600,
RestartCount: 0,
OOMKilled: &oomKilled,
CreatedAt: now.Add(-2 * time.Hour),
StartedAt: &startedAt,
Ports: []DockerContainerPort{
{PrivatePort: 80, PublicPort: 8080, Protocol: "tcp", IP: "0.0.0.0"},
},
Networks: []DockerContainerNetworkLink{
{Name: "bridge", IPv4: "172.17.0.2"},
},
BlockIO: &DockerContainerBlockIO{
ReadBytes: 1000000,
WriteBytes: 500000,
},
Mounts: []DockerContainerMount{
{Type: "bind", Source: "/host/data", Destination: "/data", RW: true},
},
}
frontend := container.ToFrontend()
if frontend.ID != container.ID {
t.Errorf("ID = %q, want %q", frontend.ID, container.ID)
}
if frontend.Name != container.Name {
t.Errorf("Name = %q, want %q", frontend.Name, container.Name)
}
if frontend.State != container.State {
t.Errorf("State = %q, want %q", frontend.State, container.State)
}
if frontend.Health != container.Health {
t.Errorf("Health = %q, want %q", frontend.Health, container.Health)
}
if frontend.CPUPercent != container.CPUPercent {
t.Errorf("CPUPercent = %f, want %f", frontend.CPUPercent, container.CPUPercent)
}
if frontend.MemoryPercent != container.MemoryPercent {
t.Errorf("MemoryPercent = %f, want %f", frontend.MemoryPercent, container.MemoryPercent)
}
if len(frontend.Ports) != 1 {
t.Errorf("Ports length = %d, want 1", len(frontend.Ports))
}
if len(frontend.Networks) != 1 {
t.Errorf("Networks length = %d, want 1", len(frontend.Networks))
}
if frontend.BlockIO == nil {
t.Error("BlockIO should not be nil")
}
if len(frontend.Mounts) != 1 {
t.Errorf("Mounts length = %d, want 1", len(frontend.Mounts))
}
if frontend.StartedAt == nil {
t.Error("StartedAt should not be nil")
}
if frontend.OOMKilled == nil || *frontend.OOMKilled {
t.Fatalf("OOMKilled = %v, want explicit false", frontend.OOMKilled)
}
oomKilled = true
if *frontend.OOMKilled {
t.Fatal("frontend OOMKilled must not alias the internal model")
}
}
func TestDockerServiceToFrontend(t *testing.T) {
now := time.Now()
createdAt := now.Add(-24 * time.Hour)
updatedAt := now.Add(-1 * time.Hour)
service := DockerService{
ID: "svc123",
Name: "web",
Stack: "mystack",
Image: "nginx:latest",
Mode: "replicated",
DesiredTasks: 3,
RunningTasks: 3,
CompletedTasks: 0,
Labels: map[string]string{"app": "web"},
EndpointPorts: []DockerServicePort{
{Name: "http", Protocol: "tcp", TargetPort: 80, PublishedPort: 8080, PublishMode: "ingress"},
},
CreatedAt: &createdAt,
UpdatedAt: &updatedAt,
}
frontend := service.ToFrontend()
if frontend.ID != service.ID {
t.Errorf("ID = %q, want %q", frontend.ID, service.ID)
}
if frontend.Name != service.Name {
t.Errorf("Name = %q, want %q", frontend.Name, service.Name)
}
if frontend.Mode != service.Mode {
t.Errorf("Mode = %q, want %q", frontend.Mode, service.Mode)
}
if frontend.DesiredTasks != service.DesiredTasks {
t.Errorf("DesiredTasks = %d, want %d", frontend.DesiredTasks, service.DesiredTasks)
}
if len(frontend.EndpointPorts) != 1 {
t.Errorf("EndpointPorts length = %d, want 1", len(frontend.EndpointPorts))
}
if frontend.CreatedAt == nil {
t.Error("CreatedAt should not be nil")
}
}
func TestDockerSwarmInfoToFrontend(t *testing.T) {
swarm := DockerSwarmInfo{
NodeID: "node123",
NodeRole: "manager",
LocalState: "active",
ControlAvailable: true,
ClusterID: "cluster456",
ClusterName: "my-swarm",
Scope: "swarm",
}
frontend := swarm.ToFrontend()
if frontend.NodeID != swarm.NodeID {
t.Errorf("NodeID = %q, want %q", frontend.NodeID, swarm.NodeID)
}
if frontend.NodeRole != swarm.NodeRole {
t.Errorf("NodeRole = %q, want %q", frontend.NodeRole, swarm.NodeRole)
}
if frontend.ControlAvailable != swarm.ControlAvailable {
t.Errorf("ControlAvailable = %v, want %v", frontend.ControlAvailable, swarm.ControlAvailable)
}
}
func TestStorageToFrontend(t *testing.T) {
storage := Storage{
ID: "storage-1",
Name: "local-lvm",
Node: "pve1",
Instance: "default",
Nodes: []string{"pve1", "pve2"},
NodeIDs: []string{"pve1", "pve2"},
NodeCount: 2,
Type: "lvmthin",
Status: "available",
Pool: "rpool/data",
Total: 1000000000000,
Used: 500000000000,
Free: 500000000000,
Usage: 50.0,
Content: "images,rootdir",
Shared: false,
Enabled: true,
Active: true,
ZFSPool: &ZFSPool{
Name: "rpool",
State: "ONLINE",
},
}
frontend := storage.ToFrontend()
if frontend.ID != storage.ID {
t.Errorf("ID = %q, want %q", frontend.ID, storage.ID)
}
if frontend.Storage != storage.Name {
t.Errorf("Storage = %q, want %q", frontend.Storage, storage.Name)
}
if frontend.Type != storage.Type {
t.Errorf("Type = %q, want %q", frontend.Type, storage.Type)
}
if frontend.Pool != storage.Pool {
t.Errorf("Pool = %q, want %q", frontend.Pool, storage.Pool)
}
if frontend.Total != storage.Total {
t.Errorf("Total = %d, want %d", frontend.Total, storage.Total)
}
if frontend.Used != storage.Used {
t.Errorf("Used = %d, want %d", frontend.Used, storage.Used)
}
if frontend.Free != storage.Free {
t.Errorf("Free = %d, want %d", frontend.Free, storage.Free)
}
if frontend.Avail != storage.Free {
t.Errorf("Avail = %d, want %d", frontend.Avail, storage.Free)
}
if frontend.ZFSPool == nil || frontend.ZFSPool.Name != "rpool" {
t.Fatalf("ZFSPool = %#v, want rpool", frontend.ZFSPool)
}
}
func TestHostSensorSummaryToFrontend(t *testing.T) {
tests := []struct {
name string
input HostSensorSummary
nilCheck bool
}{
{
name: "empty sensors",
input: HostSensorSummary{},
nilCheck: true,
},
{
name: "with temperatures",
input: HostSensorSummary{
TemperatureCelsius: map[string]float64{"cpu": 45.0},
},
nilCheck: false,
},
{
name: "with fan RPM",
input: HostSensorSummary{
FanRPM: map[string]float64{"cpu_fan": 1200.0},
},
nilCheck: false,
},
{
name: "with power watts",
input: HostSensorSummary{
PowerWatts: map[string]float64{"cpu_package": 82.4},
},
nilCheck: false,
},
{
name: "with additional",
input: HostSensorSummary{
Additional: map[string]float64{"voltage": 12.0},
},
nilCheck: false,
},
{
name: "with thermal state",
input: HostSensorSummary{
ThermalState: &HostThermalState{
Source: "pmset",
Pressure: "nominal",
LimitsPercent: map[string]int{"cpu_speed_limit": 100},
},
},
nilCheck: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := hostSensorSummaryToFrontend(tc.input)
if tc.nilCheck {
if result != nil {
t.Errorf("hostSensorSummaryToFrontend should return nil for empty sensors")
}
return
}
if result == nil {
t.Error("hostSensorSummaryToFrontend should not return nil for non-empty sensors")
}
if len(tc.input.PowerWatts) > 0 {
result.PowerWatts["cpu_package"] = 1
if tc.input.PowerWatts["cpu_package"] != 82.4 {
t.Fatal("frontend power sensors should not share map with source")
}
}
if tc.input.ThermalState != nil {
if result.ThermalState == nil || result.ThermalState.Pressure != tc.input.ThermalState.Pressure {
t.Fatalf("thermal state not copied to frontend: %+v", result.ThermalState)
}
result.ThermalState.LimitsPercent["cpu_speed_limit"] = 80
if tc.input.ThermalState.LimitsPercent["cpu_speed_limit"] != 100 {
t.Fatal("frontend thermal state should not share limit map with source")
}
}
})
}
}
func TestToDockerHostCommandFrontend(t *testing.T) {
now := time.Now()
dispatched := now.Add(-time.Minute)
acknowledged := now.Add(-30 * time.Second)
completed := now.Add(-10 * time.Second)
expires := now.Add(time.Hour)
t.Run("basic fields", func(t *testing.T) {
cmd := DockerHostCommandStatus{
ID: "cmd-123",
Type: "update",
Status: "pending",
Message: "Update requested",
CreatedAt: now,
UpdatedAt: now,
}
result := toDockerHostCommandFrontend(cmd)
if result.ID != "cmd-123" {
t.Errorf("ID = %q, want %q", result.ID, "cmd-123")
}
if result.Type != "update" {
t.Errorf("Type = %q, want %q", result.Type, "update")
}
if result.Status != "pending" {
t.Errorf("Status = %q, want %q", result.Status, "pending")
}
if result.Message != "Update requested" {
t.Errorf("Message = %q, want %q", result.Message, "Update requested")
}
if result.CreatedAt != now.Unix()*1000 {
t.Errorf("CreatedAt = %d, want %d", result.CreatedAt, now.Unix()*1000)
}
if result.UpdatedAt != now.Unix()*1000 {
t.Errorf("UpdatedAt = %d, want %d", result.UpdatedAt, now.Unix()*1000)
}
})
t.Run("optional timestamps nil", func(t *testing.T) {
cmd := DockerHostCommandStatus{
ID: "cmd-456",
Type: "restart",
Status: "pending",
CreatedAt: now,
UpdatedAt: now,
}
result := toDockerHostCommandFrontend(cmd)
if result.DispatchedAt != nil {
t.Errorf("DispatchedAt = %v, want nil", result.DispatchedAt)
}
if result.AcknowledgedAt != nil {
t.Errorf("AcknowledgedAt = %v, want nil", result.AcknowledgedAt)
}
if result.CompletedAt != nil {
t.Errorf("CompletedAt = %v, want nil", result.CompletedAt)
}
if result.FailedAt != nil {
t.Errorf("FailedAt = %v, want nil", result.FailedAt)
}
if result.ExpiresAt != nil {
t.Errorf("ExpiresAt = %v, want nil", result.ExpiresAt)
}
})
t.Run("all optional timestamps set", func(t *testing.T) {
failed := now.Add(-5 * time.Second)
cmd := DockerHostCommandStatus{
ID: "cmd-789",
Type: "update",
Status: "completed",
CreatedAt: now,
UpdatedAt: now,
DispatchedAt: &dispatched,
AcknowledgedAt: &acknowledged,
CompletedAt: &completed,
FailedAt: &failed,
ExpiresAt: &expires,
}
result := toDockerHostCommandFrontend(cmd)
if result.DispatchedAt == nil || *result.DispatchedAt != dispatched.Unix()*1000 {
t.Errorf("DispatchedAt = %v, want %d", result.DispatchedAt, dispatched.Unix()*1000)
}
if result.AcknowledgedAt == nil || *result.AcknowledgedAt != acknowledged.Unix()*1000 {
t.Errorf("AcknowledgedAt = %v, want %d", result.AcknowledgedAt, acknowledged.Unix()*1000)
}
if result.CompletedAt == nil || *result.CompletedAt != completed.Unix()*1000 {
t.Errorf("CompletedAt = %v, want %d", result.CompletedAt, completed.Unix()*1000)
}
if result.FailedAt == nil || *result.FailedAt != failed.Unix()*1000 {
t.Errorf("FailedAt = %v, want %d", result.FailedAt, failed.Unix()*1000)
}
if result.ExpiresAt == nil || *result.ExpiresAt != expires.Unix()*1000 {
t.Errorf("ExpiresAt = %v, want %d", result.ExpiresAt, expires.Unix()*1000)
}
})
t.Run("failure reason set", func(t *testing.T) {
cmd := DockerHostCommandStatus{
ID: "cmd-fail",
Type: "update",
Status: "failed",
FailureReason: "Connection timeout",
CreatedAt: now,
UpdatedAt: now,
}
result := toDockerHostCommandFrontend(cmd)
if result.FailureReason != "Connection timeout" {
t.Errorf("FailureReason = %q, want %q", result.FailureReason, "Connection timeout")
}
})
t.Run("empty failure reason", func(t *testing.T) {
cmd := DockerHostCommandStatus{
ID: "cmd-ok",
Type: "update",
Status: "completed",
FailureReason: "",
CreatedAt: now,
UpdatedAt: now,
}
result := toDockerHostCommandFrontend(cmd)
if result.FailureReason != "" {
t.Errorf("FailureReason = %q, want empty", result.FailureReason)
}
})
}
func TestVMToFrontend_EmptyTags(t *testing.T) {
vm := VM{
Tags: []string{},
LastSeen: time.Now(),
}
frontend := vm.ToFrontend()
if frontend.Tags != "" {
t.Errorf("Tags = %q, want empty string", frontend.Tags)
}
}
func TestVMToFrontend_ZeroLastBackup(t *testing.T) {
vm := VM{
LastBackup: time.Time{}, // zero value
LastSeen: time.Now(),
}
frontend := vm.ToFrontend()
if frontend.LastBackup != 0 {
t.Errorf("LastBackup = %d, want 0 for zero time", frontend.LastBackup)
}
}
func TestDockerServicePortToFrontend(t *testing.T) {
port := DockerServicePort{
Name: "http",
Protocol: "tcp",
TargetPort: 80,
PublishedPort: 8080,
PublishMode: "ingress",
}
frontend := port.ToFrontend()
if frontend.Name != port.Name {
t.Errorf("Name = %q, want %q", frontend.Name, port.Name)
}
if frontend.Protocol != port.Protocol {
t.Errorf("Protocol = %q, want %q", frontend.Protocol, port.Protocol)
}
if frontend.TargetPort != port.TargetPort {
t.Errorf("TargetPort = %d, want %d", frontend.TargetPort, port.TargetPort)
}
if frontend.PublishedPort != port.PublishedPort {
t.Errorf("PublishedPort = %d, want %d", frontend.PublishedPort, port.PublishedPort)
}
if frontend.PublishMode != port.PublishMode {
t.Errorf("PublishMode = %q, want %q", frontend.PublishMode, port.PublishMode)
}
}
func TestDockerTaskToFrontend(t *testing.T) {
now := time.Now()
updatedAt := now.Add(-5 * time.Minute)
startedAt := now.Add(-10 * time.Minute)
task := DockerTask{
ID: "task123",
ServiceID: "svc456",
ServiceName: "web",
Slot: 1,
NodeID: "node789",
NodeName: "docker-node-1",
DesiredState: "running",
CurrentState: "running",
ContainerID: "container000",
CreatedAt: now.Add(-15 * time.Minute),
UpdatedAt: &updatedAt,
StartedAt: &startedAt,
}
frontend := task.ToFrontend()
if frontend.ID != task.ID {
t.Errorf("ID = %q, want %q", frontend.ID, task.ID)
}
if frontend.ServiceName != task.ServiceName {
t.Errorf("ServiceName = %q, want %q", frontend.ServiceName, task.ServiceName)
}
if frontend.Slot != task.Slot {
t.Errorf("Slot = %d, want %d", frontend.Slot, task.Slot)
}
if frontend.DesiredState != task.DesiredState {
t.Errorf("DesiredState = %q, want %q", frontend.DesiredState, task.DesiredState)
}
if frontend.CreatedAt == nil {
t.Error("CreatedAt should not be nil")
}
if frontend.UpdatedAt == nil {
t.Error("UpdatedAt should not be nil")
}
if frontend.StartedAt == nil {
t.Error("StartedAt should not be nil")
}
}
func TestVMToFrontend_TagsJoinedCorrectly(t *testing.T) {
vm := VM{
Tags: []string{"tag1", "tag2", "tag3"},
LastSeen: time.Now(),
}
frontend := vm.ToFrontend()
// Tags should be comma-separated
if !strings.Contains(frontend.Tags, ",") {
t.Error("Tags should be comma-separated")
}
if frontend.Tags != "tag1,tag2,tag3" {
t.Errorf("Tags = %q, want 'tag1,tag2,tag3'", frontend.Tags)
}
}
func TestDockerNodeToFrontend(t *testing.T) {
now := time.Now()
updatedAt := now.Add(5 * time.Minute)
node := DockerNode{
ID: "node-1",
Hostname: "manager-1",
Role: "manager",
Availability: "active",
State: "ready",
ManagerReachability: "reachable",
ManagerAddress: "192.0.2.10:2377",
Leader: true,
EngineVersion: "27.5.1",
OS: "linux",
Architecture: "amd64",
NanoCPUs: 4_000_000_000,
MemoryBytes: 16 * 1024 * 1024 * 1024,
Labels: map[string]string{"zone": "rack-a"},
EngineLabels: map[string]string{"engine": "primary"},
CreatedAt: now,
UpdatedAt: &updatedAt,
}
frontend := node.ToFrontend()
if frontend.ID != node.ID || frontend.Hostname != node.Hostname || frontend.Role != node.Role {
t.Fatalf("unexpected frontend node identity: %+v", frontend)
}
if frontend.ManagerReachability != node.ManagerReachability || !frontend.Leader {
t.Fatalf("unexpected frontend manager metadata: %+v", frontend)
}
if frontend.CreatedAt == nil || *frontend.CreatedAt != now.Unix()*1000 {
t.Fatalf("CreatedAt = %v, want %d", frontend.CreatedAt, now.Unix()*1000)
}
if frontend.UpdatedAt == nil || *frontend.UpdatedAt != updatedAt.Unix()*1000 {
t.Fatalf("UpdatedAt = %v, want %d", frontend.UpdatedAt, updatedAt.Unix()*1000)
}
}
func TestDockerHostToFrontend(t *testing.T) {
now := time.Now()
tokenLastUsed := now.Add(-1 * time.Hour)
host := DockerHost{
ID: "dh-123",
AgentID: "agent-456",
Hostname: "docker-server",
DisplayName: "Docker Server",
CustomDisplayName: "My Docker Server",
MachineID: "machine-789",
OS: "linux",
KernelVersion: "5.15.0",
Architecture: "amd64",
Runtime: "docker",
RuntimeVersion: "24.0.0",
DockerVersion: "24.0.0",
CollectionMode: "typed-helper-summary",
CPUs: 8,
TotalMemoryBytes: 16000000000,
UptimeSeconds: 86400,
CPUUsage: 25.5,
Status: "online",
LastSeen: now,
IntervalSeconds: 30,
AgentVersion: "1.0.0",
TokenID: "token-123",
TokenName: "my-token",
TokenHint: "abc***xyz",
TokenLastUsedAt: &tokenLastUsed,
LoadAverage: []float64{1.0, 0.8, 0.5},
Memory: Memory{Total: 16000000000, Used: 8000000000},
Disks: []Disk{{Total: 500000000000, Used: 250000000000}},
NetworkInterfaces: []HostNetworkInterface{{Name: "eth0", Addresses: []string{"192.168.1.100"}}},
Containers: []DockerContainer{
{ID: "ct-1", Name: "nginx", State: "running"},
},
Nodes: []DockerNode{
{ID: "node-1", Hostname: "manager-1", Role: "manager"},
},
}
frontend := host.ToFrontend()
if frontend.ID != host.ID {
t.Errorf("ID = %q, want %q", frontend.ID, host.ID)
}
if frontend.AgentID != host.AgentID {
t.Errorf("AgentID = %q, want %q", frontend.AgentID, host.AgentID)
}
if frontend.Hostname != host.Hostname {
t.Errorf("Hostname = %q, want %q", frontend.Hostname, host.Hostname)
}
if frontend.DisplayName != host.DisplayName {
t.Errorf("DisplayName = %q, want %q", frontend.DisplayName, host.DisplayName)
}
if frontend.CollectionMode != host.CollectionMode {
t.Errorf("CollectionMode = %q, want %q", frontend.CollectionMode, host.CollectionMode)
}
if frontend.CPUUsagePercent != host.CPUUsage {
t.Errorf("CPUUsagePercent = %f, want %f", frontend.CPUUsagePercent, host.CPUUsage)
}
if frontend.LastSeen != now.Unix()*1000 {
t.Errorf("LastSeen = %d, want %d", frontend.LastSeen, now.Unix()*1000)
}
if frontend.TokenID != host.TokenID {
t.Errorf("TokenID = %q, want %q", frontend.TokenID, host.TokenID)
}
if frontend.TokenLastUsedAt == nil || *frontend.TokenLastUsedAt != tokenLastUsed.Unix()*1000 {
t.Errorf("TokenLastUsedAt = %v, want %d", frontend.TokenLastUsedAt, tokenLastUsed.Unix()*1000)
}
if len(frontend.LoadAverage) != 3 {
t.Errorf("LoadAverage length = %d, want 3", len(frontend.LoadAverage))
}
if frontend.Memory == nil {
t.Error("Memory should not be nil")
}
if len(frontend.Disks) != 1 {
t.Errorf("Disks length = %d, want 1", len(frontend.Disks))
}
if len(frontend.NetworkInterfaces) != 1 {
t.Errorf("NetworkInterfaces length = %d, want 1", len(frontend.NetworkInterfaces))
}
if len(frontend.Containers) != 1 {
t.Errorf("Containers length = %d, want 1", len(frontend.Containers))
}
if len(frontend.Nodes) != 1 {
t.Errorf("Nodes length = %d, want 1", len(frontend.Nodes))
}
}
func TestDockerHostToFrontend_EmptyDisplayName(t *testing.T) {
host := DockerHost{
Hostname: "docker-host",
DisplayName: "",
LastSeen: time.Now(),
}
frontend := host.ToFrontend()
// Should fall back to Hostname
if frontend.DisplayName != host.Hostname {
t.Errorf("DisplayName = %q, want %q (should fallback to Hostname)", frontend.DisplayName, host.Hostname)
}
}
func TestDockerHostToFrontend_WithSwarm(t *testing.T) {
host := DockerHost{
LastSeen: time.Now(),
Swarm: &DockerSwarmInfo{
NodeID: "node-123",
NodeRole: "manager",
},
}
frontend := host.ToFrontend()
if frontend.Swarm == nil {
t.Error("Swarm should not be nil")
}
if frontend.Swarm.NodeID != "node-123" {
t.Errorf("Swarm.NodeID = %q, want %q", frontend.Swarm.NodeID, "node-123")
}
}
func TestDockerHostToFrontend_WithServices(t *testing.T) {
host := DockerHost{
LastSeen: time.Now(),
Services: []DockerService{
{ID: "svc-1", Name: "web"},
{ID: "svc-2", Name: "api"},
},
}
frontend := host.ToFrontend()
if len(frontend.Services) != 2 {
t.Errorf("Services length = %d, want 2", len(frontend.Services))
}
}
func TestDockerHostToFrontend_WithTasks(t *testing.T) {
host := DockerHost{
LastSeen: time.Now(),
Tasks: []DockerTask{
{ID: "task-1", ServiceName: "web"},
},
}
frontend := host.ToFrontend()
if len(frontend.Tasks) != 1 {
t.Errorf("Tasks length = %d, want 1", len(frontend.Tasks))
}
}
func TestDockerHostToFrontend_WithCommand(t *testing.T) {
now := time.Now()
host := DockerHost{
LastSeen: now,
Command: &DockerHostCommandStatus{
ID: "cmd-123",
Type: "update",
Status: "pending",
CreatedAt: now,
UpdatedAt: now,
},
}
frontend := host.ToFrontend()
if frontend.Command == nil {
t.Error("Command should not be nil")
}
if frontend.Command.ID != "cmd-123" {
t.Errorf("Command.ID = %q, want %q", frontend.Command.ID, "cmd-123")
}
}
func TestHostToFrontend(t *testing.T) {
now := time.Now()
tokenLastUsed := now.Add(-2 * time.Hour)
host := Host{
ID: "host-123",
Hostname: "server1",
DisplayName: "Server 1",
Platform: "linux",
OSName: "Ubuntu",
OSVersion: "22.04",
KernelVersion: "5.15.0-generic",
Architecture: "amd64",
CPUCount: 16,
CPUUsage: 35.0,
Status: "online",
UptimeSeconds: 172800,
IntervalSeconds: 60,
AgentVersion: "1.2.0",
TokenID: "tok-456",
TokenName: "host-token",
TokenHint: "xyz***",
TokenLastUsedAt: &tokenLastUsed,
Tags: []string{"production", "web"},
LastSeen: now,
LoadAverage: []float64{2.0, 1.5, 1.0},
Memory: Memory{Total: 32000000000, Used: 16000000000},
Disks: []Disk{{Total: 1000000000000, Used: 500000000000}},
NetworkInterfaces: []HostNetworkInterface{
{Name: "eth0", Addresses: []string{"10.0.0.50"}},
},
Sensors: HostSensorSummary{
TemperatureCelsius: map[string]float64{"cpu": 55.0},
},
CommandsEnabled: true,
IsLegacy: false,
LinkedNodeID: "node-abc",
}
frontend := host.ToFrontend()
if frontend.ID != host.ID {
t.Errorf("ID = %q, want %q", frontend.ID, host.ID)
}
if frontend.Hostname != host.Hostname {
t.Errorf("Hostname = %q, want %q", frontend.Hostname, host.Hostname)
}
if frontend.DisplayName != host.DisplayName {
t.Errorf("DisplayName = %q, want %q", frontend.DisplayName, host.DisplayName)
}
if frontend.Platform != host.Platform {
t.Errorf("Platform = %q, want %q", frontend.Platform, host.Platform)
}
if frontend.CPUCount != host.CPUCount {
t.Errorf("CPUCount = %d, want %d", frontend.CPUCount, host.CPUCount)
}
if frontend.CPUUsage != host.CPUUsage {
t.Errorf("CPUUsage = %f, want %f", frontend.CPUUsage, host.CPUUsage)
}
if frontend.LastSeen != now.Unix()*1000 {
t.Errorf("LastSeen = %d, want %d", frontend.LastSeen, now.Unix()*1000)
}
if len(frontend.Tags) != 2 {
t.Errorf("Tags length = %d, want 2", len(frontend.Tags))
}
if len(frontend.LoadAverage) != 3 {
t.Errorf("LoadAverage length = %d, want 3", len(frontend.LoadAverage))
}
if frontend.Memory == nil {
t.Error("Memory should not be nil")
}
if len(frontend.Disks) != 1 {
t.Errorf("Disks length = %d, want 1", len(frontend.Disks))
}
if len(frontend.NetworkInterfaces) != 1 {
t.Errorf("NetworkInterfaces length = %d, want 1", len(frontend.NetworkInterfaces))
}
if frontend.Sensors == nil {
t.Error("Sensors should not be nil")
}
if frontend.CommandsEnabled != host.CommandsEnabled {
t.Errorf("CommandsEnabled = %v, want %v", frontend.CommandsEnabled, host.CommandsEnabled)
}
if frontend.IsLegacy != host.IsLegacy {
t.Errorf("IsLegacy = %v, want %v", frontend.IsLegacy, host.IsLegacy)
}
if frontend.LinkedNodeID != host.LinkedNodeID {
t.Errorf("LinkedNodeID = %q, want %q", frontend.LinkedNodeID, host.LinkedNodeID)
}
if frontend.TokenLastUsedAt == nil || *frontend.TokenLastUsedAt != tokenLastUsed.Unix()*1000 {
t.Errorf("TokenLastUsedAt = %v, want %d", frontend.TokenLastUsedAt, tokenLastUsed.Unix()*1000)
}
}
func TestHostToFrontend_DisplayNameFallback(t *testing.T) {
tests := []struct {
name string
displayName string
hostname string
expected string
}{
{
name: "both empty",
displayName: "",
hostname: "",
expected: "",
},
{
name: "displayName set",
displayName: "My Server",
hostname: "server1",
expected: "My Server",
},
{
name: "only hostname",
displayName: "",
hostname: "server1",
expected: "server1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host := Host{
DisplayName: tc.displayName,
Hostname: tc.hostname,
LastSeen: time.Now(),
}
frontend := host.ToFrontend()
if frontend.DisplayName != tc.expected {
t.Errorf("DisplayName = %q, want %q", frontend.DisplayName, tc.expected)
}
})
}
}
func TestReplicationJobToFrontend(t *testing.T) {
now := time.Now()
lastSync := now.Add(-1 * time.Hour)
nextSync := now.Add(1 * time.Hour)
job := ReplicationJob{
ID: "rep-123",
Instance: "default",
JobID: "job-456",
JobNumber: 1,
Guest: "vm/100",
GuestID: 100,
GuestName: "test-vm",
GuestType: "qemu",
GuestNode: "pve1",
SourceNode: "pve1",
SourceStorage: "local-lvm",
TargetNode: "pve2",
TargetStorage: "local-lvm",
Schedule: "*/15 * * * *",
Type: "local",
Enabled: true,
State: "active",
Status: "OK",
LastSyncStatus: "success",
LastSyncUnix: lastSync.Unix(),
LastSyncDurationSeconds: 120,
LastSyncDurationHuman: "2m",
NextSyncUnix: nextSync.Unix(),
DurationSeconds: 120,
DurationHuman: "2m",
FailCount: 0,
Comment: "Daily replication",
LastSyncTime: &lastSync,
NextSyncTime: &nextSync,
LastPolled: now,
}
frontend := job.ToFrontend()
if frontend.ID != job.ID {
t.Errorf("ID = %q, want %q", frontend.ID, job.ID)
}
if frontend.JobID != job.JobID {
t.Errorf("JobID = %q, want %q", frontend.JobID, job.JobID)
}
if frontend.GuestID != job.GuestID {
t.Errorf("GuestID = %d, want %d", frontend.GuestID, job.GuestID)
}
if frontend.GuestName != job.GuestName {
t.Errorf("GuestName = %q, want %q", frontend.GuestName, job.GuestName)
}
if frontend.SourceNode != job.SourceNode {
t.Errorf("SourceNode = %q, want %q", frontend.SourceNode, job.SourceNode)
}
if frontend.TargetNode != job.TargetNode {
t.Errorf("TargetNode = %q, want %q", frontend.TargetNode, job.TargetNode)
}
if frontend.Enabled != job.Enabled {
t.Errorf("Enabled = %v, want %v", frontend.Enabled, job.Enabled)
}
if frontend.LastSyncStatus != job.LastSyncStatus {
t.Errorf("LastSyncStatus = %q, want %q", frontend.LastSyncStatus, job.LastSyncStatus)
}
if frontend.LastSyncTime != lastSync.UnixMilli() {
t.Errorf("LastSyncTime = %d, want %d", frontend.LastSyncTime, lastSync.UnixMilli())
}
if frontend.NextSyncTime != nextSync.UnixMilli() {
t.Errorf("NextSyncTime = %d, want %d", frontend.NextSyncTime, nextSync.UnixMilli())
}
if frontend.PolledAt != now.UnixMilli() {
t.Errorf("PolledAt = %d, want %d", frontend.PolledAt, now.UnixMilli())
}
}
func TestReplicationJobToFrontend_NilTimes(t *testing.T) {
job := ReplicationJob{
ID: "rep-nil",
LastSyncTime: nil,
NextSyncTime: nil,
LastPolled: time.Time{}, // zero value
}
frontend := job.ToFrontend()
if frontend.LastSyncTime != 0 {
t.Errorf("LastSyncTime = %d, want 0 for nil", frontend.LastSyncTime)
}
if frontend.NextSyncTime != 0 {
t.Errorf("NextSyncTime = %d, want 0 for nil", frontend.NextSyncTime)
}
// PolledAt defaults to now if LastPolled is zero
if frontend.PolledAt == 0 {
t.Error("PolledAt should not be 0 (defaults to now)")
}
}
func TestDockerServiceUpdateToFrontend(t *testing.T) {
completedAt := time.Now().Add(-5 * time.Minute)
update := DockerServiceUpdate{
State: "completed",
Message: "Update completed successfully",
CompletedAt: &completedAt,
}
frontend := update.ToFrontend()
if frontend.State != update.State {
t.Errorf("State = %q, want %q", frontend.State, update.State)
}
if frontend.Message != update.Message {
t.Errorf("Message = %q, want %q", frontend.Message, update.Message)
}
if frontend.CompletedAt == nil || *frontend.CompletedAt != completedAt.Unix()*1000 {
t.Errorf("CompletedAt = %v, want %d", frontend.CompletedAt, completedAt.Unix()*1000)
}
}
func TestDockerServiceUpdateToFrontend_NilCompletedAt(t *testing.T) {
update := DockerServiceUpdate{
State: "updating",
CompletedAt: nil,
}
frontend := update.ToFrontend()
if frontend.CompletedAt != nil {
t.Errorf("CompletedAt = %v, want nil", frontend.CompletedAt)
}
}
func TestDockerContainerToFrontend_PodmanInfo(t *testing.T) {
container := DockerContainer{
ID: "podman-ct",
Name: "nginx",
CreatedAt: time.Now(),
Podman: &DockerPodmanContainer{
PodName: "web-pod",
PodID: "pod-123",
Infra: false,
ComposeProject: "myapp",
ComposeService: "web",
ComposeWorkdir: "/home/user/myapp",
ComposeConfigHash: "abc123",
AutoUpdatePolicy: "registry",
AutoUpdateRestart: "always",
UserNamespace: "keep-id:uid=1000,gid=1000",
},
}
frontend := container.ToFrontend()
if frontend.Podman == nil {
t.Fatal("Podman should not be nil")
}
if frontend.Podman.PodName != "web-pod" {
t.Errorf("Podman.PodName = %q, want %q", frontend.Podman.PodName, "web-pod")
}
if frontend.Podman.ComposeProject != "myapp" {
t.Errorf("Podman.ComposeProject = %q, want %q", frontend.Podman.ComposeProject, "myapp")
}
if frontend.Podman.AutoUpdatePolicy != "registry" {
t.Errorf("Podman.AutoUpdatePolicy = %q, want %q", frontend.Podman.AutoUpdatePolicy, "registry")
}
}