package models import ( "testing" "time" ) func TestUpsertHost(t *testing.T) { state := NewState() // Test insert new host host1 := Host{ ID: "host-1", Hostname: "server-1", Status: "online", } state.UpsertHost(host1) hosts := state.GetHosts() if len(hosts) != 1 { t.Fatalf("Expected 1 host, got %d", len(hosts)) } if hosts[0].ID != "host-1" { t.Errorf("Expected host ID 'host-1', got %q", hosts[0].ID) } // Test update existing host host1Updated := Host{ ID: "host-1", Hostname: "server-1", Status: "offline", } state.UpsertHost(host1Updated) hosts = state.GetHosts() if len(hosts) != 1 { t.Fatalf("Expected 1 host after update, got %d", len(hosts)) } if hosts[0].Status != "offline" { t.Errorf("Expected status 'offline', got %q", hosts[0].Status) } // Test multiple hosts and sorting state.UpsertHost(Host{ID: "host-2", Hostname: "alpha-server"}) state.UpsertHost(Host{ID: "host-3", Hostname: "zulu-server"}) hosts = state.GetHosts() if len(hosts) != 3 { t.Fatalf("Expected 3 hosts, got %d", len(hosts)) } // Hosts should be sorted by hostname if hosts[0].Hostname != "alpha-server" { t.Errorf("First host should be 'alpha-server', got %q", hosts[0].Hostname) } } func TestGetHosts_Copy(t *testing.T) { state := NewState() state.UpsertHost(Host{ID: "host-1", Hostname: "server-1"}) hosts1 := state.GetHosts() hosts2 := state.GetHosts() // Modify one slice if len(hosts1) > 0 { hosts1[0].Hostname = "modified" } // Other slice should be unchanged if len(hosts2) > 0 && hosts2[0].Hostname == "modified" { t.Error("GetHosts should return a copy, not the same slice") } // Original state should be unchanged hosts3 := state.GetHosts() if hosts3[0].Hostname == "modified" { t.Error("State should be unchanged by modifications to returned slice") } } func TestRemoveHost(t *testing.T) { state := NewState() // Insert hosts state.UpsertHost(Host{ID: "host-1", Hostname: "server1"}) state.UpsertHost(Host{ID: "host-2", Hostname: "server2"}) // Remove existing host removed, ok := state.RemoveHost("host-1") if !ok { t.Error("Expected RemoveHost to return true for existing host") } if removed.ID != "host-1" { t.Errorf("Expected removed host ID 'host-1', got %q", removed.ID) } hosts := state.GetHosts() if len(hosts) != 1 { t.Fatalf("Expected 1 host after removal, got %d", len(hosts)) } if hosts[0].ID != "host-2" { t.Errorf("Remaining host should be 'host-2', got %q", hosts[0].ID) } // Remove non-existing host removed, ok = state.RemoveHost("non-existent") if ok { t.Error("Expected RemoveHost to return false for non-existent host") } if removed.ID != "" { t.Errorf("Expected empty Host for non-existent removal, got ID %q", removed.ID) } } func TestSetHostStatus(t *testing.T) { state := NewState() // Test with no hosts changed := state.SetHostStatus("host-1", "online") if changed { t.Error("SetHostStatus should return false when host doesn't exist") } // Add host and set status state.UpsertHost(Host{ID: "host-1", Hostname: "server1", Status: "offline"}) changed = state.SetHostStatus("host-1", "online") if !changed { t.Error("SetHostStatus should return true when host exists") } hosts := state.GetHosts() if hosts[0].Status != "online" { t.Errorf("Expected status 'online', got %q", hosts[0].Status) } // Set same status (no change) changed = state.SetHostStatus("host-1", "online") if !changed { t.Error("SetHostStatus should return true even when status unchanged") } } func TestTouchHost(t *testing.T) { state := NewState() now := time.Now() // Test with non-existent host ok := state.TouchHost("host-1", now) if ok { t.Error("TouchHost should return false for non-existent host") } // Add host and touch it state.UpsertHost(Host{ID: "host-1", Hostname: "server1"}) later := now.Add(time.Hour) ok = state.TouchHost("host-1", later) if !ok { t.Error("TouchHost should return true for existing host") } hosts := state.GetHosts() if !hosts[0].LastSeen.Equal(later) { t.Errorf("LastSeen should be updated to %v, got %v", later, hosts[0].LastSeen) } } func TestUpdateRecentlyResolved(t *testing.T) { state := NewState() now := time.Now() alerts := []ResolvedAlert{ { Alert: Alert{ ID: "alert-1", Type: "cpu_high", Level: "warning", ResourceName: "server-1", Message: "High CPU usage", }, ResolvedTime: now, }, { Alert: Alert{ ID: "alert-2", Type: "disk_low", Level: "critical", ResourceName: "server-2", Message: "Low disk space", }, ResolvedTime: now.Add(-time.Hour), }, } state.UpdateRecentlyResolved(alerts) // The method updates internal state - verify via snapshot or other means // Since RecentlyResolvedAlerts might be accessed differently, just verify no panic // and method executes correctly } func TestSetConnectionHealth(t *testing.T) { state := NewState() // Set connection healthy state.SetConnectionHealth("pve-cluster-1", true) snapshot := state.GetSnapshot() if healthy, ok := snapshot.ConnectionHealth["pve-cluster-1"]; !ok || !healthy { t.Error("Expected connection to be healthy") } // Set connection unhealthy state.SetConnectionHealth("pve-cluster-1", false) snapshot = state.GetSnapshot() if healthy, ok := snapshot.ConnectionHealth["pve-cluster-1"]; !ok || healthy { t.Error("Expected connection to be unhealthy") } // Multiple connections state.SetConnectionHealth("pve-cluster-2", true) state.SetConnectionHealth("pbs-instance-1", true) snapshot = state.GetSnapshot() if len(snapshot.ConnectionHealth) != 3 { t.Errorf("Expected 3 connection health entries, got %d", len(snapshot.ConnectionHealth)) } } func TestRemoveConnectionHealth(t *testing.T) { state := NewState() // Set up connection health entries state.SetConnectionHealth("pve-cluster-1", true) state.SetConnectionHealth("pve-cluster-2", false) // Remove one state.RemoveConnectionHealth("pve-cluster-1") snapshot := state.GetSnapshot() if _, ok := snapshot.ConnectionHealth["pve-cluster-1"]; ok { t.Error("Expected pve-cluster-1 to be removed") } if _, ok := snapshot.ConnectionHealth["pve-cluster-2"]; !ok { t.Error("Expected pve-cluster-2 to still exist") } // Remove non-existent (should not panic) state.RemoveConnectionHealth("non-existent") // Remove remaining state.RemoveConnectionHealth("pve-cluster-2") snapshot = state.GetSnapshot() if len(snapshot.ConnectionHealth) != 0 { t.Errorf("Expected empty connection health map, got %d entries", len(snapshot.ConnectionHealth)) } } func TestUpdatePBSBackups(t *testing.T) { state := NewState() now := time.Now() // Add backups from first instance backups1 := []PBSBackup{ {ID: "backup-1", Instance: "pbs-1", BackupTime: now}, {ID: "backup-2", Instance: "pbs-1", BackupTime: now.Add(-time.Hour)}, } state.UpdatePBSBackups("pbs-1", backups1) snapshot := state.GetSnapshot() if len(snapshot.PBSBackups) != 2 { t.Fatalf("Expected 2 backups, got %d", len(snapshot.PBSBackups)) } // Add backups from second instance backups2 := []PBSBackup{ {ID: "backup-3", Instance: "pbs-2", BackupTime: now.Add(-30 * time.Minute)}, } state.UpdatePBSBackups("pbs-2", backups2) snapshot = state.GetSnapshot() if len(snapshot.PBSBackups) != 3 { t.Fatalf("Expected 3 backups, got %d", len(snapshot.PBSBackups)) } // Update first instance (should replace its backups) backups1Updated := []PBSBackup{ {ID: "backup-4", Instance: "pbs-1", BackupTime: now.Add(time.Hour)}, } state.UpdatePBSBackups("pbs-1", backups1Updated) snapshot = state.GetSnapshot() if len(snapshot.PBSBackups) != 2 { t.Fatalf("Expected 2 backups after update, got %d", len(snapshot.PBSBackups)) } // Verify pbs-1 backups were replaced hasOldBackup := false for _, b := range snapshot.PBSBackups { if b.ID == "backup-1" || b.ID == "backup-2" { hasOldBackup = true break } } if hasOldBackup { t.Error("Old pbs-1 backups should have been replaced") } } func TestUpdatePMGBackups(t *testing.T) { state := NewState() now := time.Now() // Add backups from first instance backups1 := []PMGBackup{ {Filename: "backup-1.conf", Instance: "pmg-1", BackupTime: now}, {Filename: "backup-2.conf", Instance: "pmg-1", BackupTime: now.Add(-time.Hour)}, } state.UpdatePMGBackups("pmg-1", backups1) snapshot := state.GetSnapshot() if len(snapshot.PMGBackups) != 2 { t.Fatalf("Expected 2 backups, got %d", len(snapshot.PMGBackups)) } // Backups should be sorted by time (newest first) if snapshot.PMGBackups[0].BackupTime.Before(snapshot.PMGBackups[1].BackupTime) { t.Error("Backups should be sorted by time descending") } // Add backups from second instance backups2 := []PMGBackup{ {Filename: "backup-3.conf", Instance: "pmg-2", BackupTime: now.Add(-30 * time.Minute)}, } state.UpdatePMGBackups("pmg-2", backups2) snapshot = state.GetSnapshot() if len(snapshot.PMGBackups) != 3 { t.Fatalf("Expected 3 backups, got %d", len(snapshot.PMGBackups)) } // Update first instance with empty (should remove its backups) state.UpdatePMGBackups("pmg-1", []PMGBackup{}) snapshot = state.GetSnapshot() if len(snapshot.PMGBackups) != 1 { t.Fatalf("Expected 1 backup after clearing pmg-1, got %d", len(snapshot.PMGBackups)) } if snapshot.PMGBackups[0].Instance != "pmg-2" { t.Error("Remaining backup should be from pmg-2") } } func TestUpdatePhysicalDisks(t *testing.T) { state := NewState() // Add disks from first instance disks1 := []PhysicalDisk{ {ID: "disk-1", Instance: "pve-1", Node: "node1", DevPath: "/dev/sda"}, {ID: "disk-2", Instance: "pve-1", Node: "node1", DevPath: "/dev/sdb"}, } state.UpdatePhysicalDisks("pve-1", disks1) snapshot := state.GetSnapshot() if len(snapshot.PhysicalDisks) != 2 { t.Fatalf("Expected 2 disks, got %d", len(snapshot.PhysicalDisks)) } // Add disks from second instance disks2 := []PhysicalDisk{ {ID: "disk-3", Instance: "pve-2", Node: "node2", DevPath: "/dev/sda"}, } state.UpdatePhysicalDisks("pve-2", disks2) snapshot = state.GetSnapshot() if len(snapshot.PhysicalDisks) != 3 { t.Fatalf("Expected 3 disks, got %d", len(snapshot.PhysicalDisks)) } // Update first instance (should replace its disks) disks1Updated := []PhysicalDisk{ {ID: "disk-4", Instance: "pve-1", Node: "node1", DevPath: "/dev/nvme0n1"}, } state.UpdatePhysicalDisks("pve-1", disks1Updated) snapshot = state.GetSnapshot() if len(snapshot.PhysicalDisks) != 2 { t.Fatalf("Expected 2 disks after update, got %d", len(snapshot.PhysicalDisks)) } // Verify sorting by node then devpath if snapshot.PhysicalDisks[0].Node > snapshot.PhysicalDisks[1].Node { t.Error("Disks should be sorted by node") } } func TestUpdateStorageForInstance(t *testing.T) { state := NewState() // Add storage from first instance storage1 := []Storage{ {ID: "storage-1", Instance: "pve-1", Name: "local"}, {ID: "storage-2", Instance: "pve-1", Name: "ceph-pool"}, } state.UpdateStorageForInstance("pve-1", storage1) snapshot := state.GetSnapshot() if len(snapshot.Storage) != 2 { t.Fatalf("Expected 2 storage entries, got %d", len(snapshot.Storage)) } // Add storage from second instance storage2 := []Storage{ {ID: "storage-3", Instance: "pve-2", Name: "local"}, } state.UpdateStorageForInstance("pve-2", storage2) snapshot = state.GetSnapshot() if len(snapshot.Storage) != 3 { t.Fatalf("Expected 3 storage entries, got %d", len(snapshot.Storage)) } // Update first instance with empty (should remove its storage) state.UpdateStorageForInstance("pve-1", []Storage{}) snapshot = state.GetSnapshot() if len(snapshot.Storage) != 1 { t.Fatalf("Expected 1 storage after clearing pve-1, got %d", len(snapshot.Storage)) } } func TestUpdatePBSInstances(t *testing.T) { state := NewState() instances := []PBSInstance{ {ID: "pbs-1", Name: "Backup Server 1"}, {ID: "pbs-2", Name: "Backup Server 2"}, } state.UpdatePBSInstances(instances) snapshot := state.GetSnapshot() if len(snapshot.PBSInstances) != 2 { t.Fatalf("Expected 2 PBS instances, got %d", len(snapshot.PBSInstances)) } // Replace with different instances newInstances := []PBSInstance{ {ID: "pbs-3", Name: "New Backup Server"}, } state.UpdatePBSInstances(newInstances) snapshot = state.GetSnapshot() if len(snapshot.PBSInstances) != 1 { t.Fatalf("Expected 1 PBS instance after replacement, got %d", len(snapshot.PBSInstances)) } if snapshot.PBSInstances[0].ID != "pbs-3" { t.Error("Expected pbs-3 instance") } } func TestUpdatePBSInstance(t *testing.T) { state := NewState() // Add first instance state.UpdatePBSInstance(PBSInstance{ID: "pbs-1", Name: "Server 1"}) snapshot := state.GetSnapshot() if len(snapshot.PBSInstances) != 1 { t.Fatalf("Expected 1 PBS instance, got %d", len(snapshot.PBSInstances)) } // Add second instance state.UpdatePBSInstance(PBSInstance{ID: "pbs-2", Name: "Server 2"}) snapshot = state.GetSnapshot() if len(snapshot.PBSInstances) != 2 { t.Fatalf("Expected 2 PBS instances, got %d", len(snapshot.PBSInstances)) } // Update existing instance state.UpdatePBSInstance(PBSInstance{ID: "pbs-1", Name: "Server 1 Updated"}) snapshot = state.GetSnapshot() if len(snapshot.PBSInstances) != 2 { t.Fatalf("Expected 2 PBS instances after update, got %d", len(snapshot.PBSInstances)) } // Find updated instance var found bool for _, inst := range snapshot.PBSInstances { if inst.ID == "pbs-1" && inst.Name == "Server 1 Updated" { found = true break } } if !found { t.Error("Expected pbs-1 to be updated with new name") } } func TestUpdatePMGInstances(t *testing.T) { state := NewState() instances := []PMGInstance{ {ID: "pmg-1", Name: "Mail Gateway 1"}, {ID: "pmg-2", Name: "Mail Gateway 2"}, } state.UpdatePMGInstances(instances) snapshot := state.GetSnapshot() if len(snapshot.PMGInstances) != 2 { t.Fatalf("Expected 2 PMG instances, got %d", len(snapshot.PMGInstances)) } // Replace with empty state.UpdatePMGInstances([]PMGInstance{}) snapshot = state.GetSnapshot() if len(snapshot.PMGInstances) != 0 { t.Fatalf("Expected 0 PMG instances after clearing, got %d", len(snapshot.PMGInstances)) } } func TestUpdatePMGInstance(t *testing.T) { state := NewState() // Add first instance state.UpdatePMGInstance(PMGInstance{ID: "pmg-1", Name: "Gateway 1"}) snapshot := state.GetSnapshot() if len(snapshot.PMGInstances) != 1 { t.Fatalf("Expected 1 PMG instance, got %d", len(snapshot.PMGInstances)) } // Add second instance state.UpdatePMGInstance(PMGInstance{ID: "pmg-2", Name: "Gateway 2"}) snapshot = state.GetSnapshot() if len(snapshot.PMGInstances) != 2 { t.Fatalf("Expected 2 PMG instances, got %d", len(snapshot.PMGInstances)) } // Update existing instance state.UpdatePMGInstance(PMGInstance{ID: "pmg-1", Name: "Gateway 1 Updated"}) snapshot = state.GetSnapshot() if len(snapshot.PMGInstances) != 2 { t.Fatalf("Expected 2 PMG instances after update, got %d", len(snapshot.PMGInstances)) } // Verify update var found bool for _, inst := range snapshot.PMGInstances { if inst.ID == "pmg-1" && inst.Name == "Gateway 1 Updated" { found = true break } } if !found { t.Error("Expected pmg-1 to be updated with new name") } } func TestUpdateCephClustersForInstance(t *testing.T) { state := NewState() // Add clusters from first instance clusters1 := []CephCluster{ {ID: "ceph-1", Instance: "pve-1", Name: "ceph-pool-1"}, {ID: "ceph-2", Instance: "pve-1", Name: "ceph-pool-2"}, } state.UpdateCephClustersForInstance("pve-1", clusters1) snapshot := state.GetSnapshot() if len(snapshot.CephClusters) != 2 { t.Fatalf("Expected 2 clusters, got %d", len(snapshot.CephClusters)) } // Add clusters from second instance clusters2 := []CephCluster{ {ID: "ceph-3", Instance: "pve-2", Name: "ceph-pool-1"}, } state.UpdateCephClustersForInstance("pve-2", clusters2) snapshot = state.GetSnapshot() if len(snapshot.CephClusters) != 3 { t.Fatalf("Expected 3 clusters, got %d", len(snapshot.CephClusters)) } // Update first instance with empty (should remove its clusters) state.UpdateCephClustersForInstance("pve-1", []CephCluster{}) snapshot = state.GetSnapshot() if len(snapshot.CephClusters) != 1 { t.Fatalf("Expected 1 cluster after clearing pve-1, got %d", len(snapshot.CephClusters)) } if snapshot.CephClusters[0].Instance != "pve-2" { t.Error("Remaining cluster should be from pve-2") } } func TestUpdateBackupTasksForInstance(t *testing.T) { state := NewState() now := time.Now() // Add tasks from first instance tasks1 := []BackupTask{ {ID: "pve-1-task-1", Instance: "pve-1", StartTime: now}, {ID: "pve-1-task-2", Instance: "pve-1", StartTime: now.Add(-time.Hour)}, } state.UpdateBackupTasksForInstance("pve-1", tasks1) snapshot := state.GetSnapshot() if len(snapshot.PVEBackups.BackupTasks) != 2 { t.Fatalf("Expected 2 tasks, got %d", len(snapshot.PVEBackups.BackupTasks)) } // Tasks should be sorted by start time descending if snapshot.PVEBackups.BackupTasks[0].StartTime.Before(snapshot.PVEBackups.BackupTasks[1].StartTime) { t.Error("Tasks should be sorted by start time descending") } // Add tasks from second instance tasks2 := []BackupTask{ {ID: "pve-2-task-1", Instance: "pve-2", StartTime: now.Add(-30 * time.Minute)}, } state.UpdateBackupTasksForInstance("pve-2", tasks2) snapshot = state.GetSnapshot() if len(snapshot.PVEBackups.BackupTasks) != 3 { t.Fatalf("Expected 3 tasks, got %d", len(snapshot.PVEBackups.BackupTasks)) } // Update first instance (should replace its tasks) tasks1Updated := []BackupTask{ {ID: "pve-1-task-3", Instance: "pve-1", StartTime: now.Add(time.Hour)}, } state.UpdateBackupTasksForInstance("pve-1", tasks1Updated) snapshot = state.GetSnapshot() if len(snapshot.PVEBackups.BackupTasks) != 2 { t.Fatalf("Expected 2 tasks after update, got %d", len(snapshot.PVEBackups.BackupTasks)) } } func TestUpdateReplicationJobsForInstance(t *testing.T) { state := NewState() // Add jobs from first instance jobs1 := []ReplicationJob{ {ID: "job-1", Instance: "pve-1", GuestID: 100, JobNumber: 0}, {ID: "job-2", Instance: "pve-1", GuestID: 101, JobNumber: 0}, } state.UpdateReplicationJobsForInstance("pve-1", jobs1) snapshot := state.GetSnapshot() if len(snapshot.ReplicationJobs) != 2 { t.Fatalf("Expected 2 jobs, got %d", len(snapshot.ReplicationJobs)) } // Add jobs from second instance jobs2 := []ReplicationJob{ {ID: "job-3", Instance: "pve-2", GuestID: 200, JobNumber: 0}, } state.UpdateReplicationJobsForInstance("pve-2", jobs2) snapshot = state.GetSnapshot() if len(snapshot.ReplicationJobs) != 3 { t.Fatalf("Expected 3 jobs, got %d", len(snapshot.ReplicationJobs)) } // Update first instance with empty (should remove its jobs) state.UpdateReplicationJobsForInstance("pve-1", []ReplicationJob{}) snapshot = state.GetSnapshot() if len(snapshot.ReplicationJobs) != 1 { t.Fatalf("Expected 1 job after clearing pve-1, got %d", len(snapshot.ReplicationJobs)) } if snapshot.ReplicationJobs[0].Instance != "pve-2" { t.Error("Remaining job should be from pve-2") } } func TestUpdateGuestSnapshotsForInstance(t *testing.T) { state := NewState() now := time.Now() // Add snapshots from first instance snapshots1 := []GuestSnapshot{ {ID: "pve-1-snap-1", Instance: "pve-1", VMID: 100, Name: "snapshot1", Time: now}, {ID: "pve-1-snap-2", Instance: "pve-1", VMID: 100, Name: "snapshot2", Time: now.Add(-time.Hour)}, } state.UpdateGuestSnapshotsForInstance("pve-1", snapshots1) snapshot := state.GetSnapshot() if len(snapshot.PVEBackups.GuestSnapshots) != 2 { t.Fatalf("Expected 2 snapshots, got %d", len(snapshot.PVEBackups.GuestSnapshots)) } // Add snapshots from second instance snapshots2 := []GuestSnapshot{ {ID: "pve-2-snap-1", Instance: "pve-2", VMID: 200, Name: "snapshot1", Time: now.Add(-30 * time.Minute)}, } state.UpdateGuestSnapshotsForInstance("pve-2", snapshots2) snapshot = state.GetSnapshot() if len(snapshot.PVEBackups.GuestSnapshots) != 3 { t.Fatalf("Expected 3 snapshots, got %d", len(snapshot.PVEBackups.GuestSnapshots)) } // Update first instance (should replace its snapshots) snapshots1Updated := []GuestSnapshot{ {ID: "pve-1-snap-3", Instance: "pve-1", VMID: 100, Name: "new-snapshot", Time: now.Add(time.Hour)}, } state.UpdateGuestSnapshotsForInstance("pve-1", snapshots1Updated) snapshot = state.GetSnapshot() if len(snapshot.PVEBackups.GuestSnapshots) != 2 { t.Fatalf("Expected 2 snapshots after update, got %d", len(snapshot.PVEBackups.GuestSnapshots)) } } func TestSyncGuestBackupTimes(t *testing.T) { state := NewState() now := time.Now() oldBackup := now.Add(-24 * time.Hour) newBackup := now.Add(-1 * time.Hour) // Set up VMs and containers state.UpdateVMs([]VM{ {VMID: 100, Name: "vm-100", Instance: "pve-1"}, {VMID: 101, Name: "vm-101", Instance: "pve-1"}, }) state.UpdateContainers([]Container{ {VMID: 200, Name: "ct-200", Instance: "pve-1"}, }) // Add storage backups for VM 100 (must include Instance for proper matching) state.mu.Lock() state.PVEBackups.StorageBackups = []StorageBackup{ {ID: "pve-1-backup-1", VMID: 100, Instance: "pve-1", Time: oldBackup}, {ID: "pve-1-backup-2", VMID: 100, Instance: "pve-1", Time: newBackup}, // newer } state.mu.Unlock() // Add PBS backup for container 200 state.UpdatePBSBackups("pbs-1", []PBSBackup{ {ID: "pbs-backup-1", VMID: "200", BackupTime: newBackup}, }) // Sync backup times state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() // VM 100 should have the newer backup time var vm100 *VM for i := range snapshot.VMs { if snapshot.VMs[i].VMID == 100 { vm100 = &snapshot.VMs[i] break } } if vm100 == nil { t.Fatal("VM 100 not found") } if !vm100.LastBackup.Equal(newBackup) { t.Errorf("VM 100 LastBackup = %v, expected %v", vm100.LastBackup, newBackup) } // VM 101 should have no backup time (zero) var vm101 *VM for i := range snapshot.VMs { if snapshot.VMs[i].VMID == 101 { vm101 = &snapshot.VMs[i] break } } if vm101 == nil { t.Fatal("VM 101 not found") } if !vm101.LastBackup.IsZero() { t.Errorf("VM 101 LastBackup should be zero, got %v", vm101.LastBackup) } // Container 200 should have backup time from PBS var ct200 *Container for i := range snapshot.Containers { if snapshot.Containers[i].VMID == 200 { ct200 = &snapshot.Containers[i] break } } if ct200 == nil { t.Fatal("Container 200 not found") } if !ct200.LastBackup.Equal(newBackup) { t.Errorf("Container 200 LastBackup = %v, expected %v", ct200.LastBackup, newBackup) } } // TestSyncGuestBackupTimesCrossInstance verifies that backup matching uses instance+VMID. // This prevents a newly created container on one instance from incorrectly showing // backup time from a different container with the same VMID on another instance. func TestSyncGuestBackupTimesCrossInstance(t *testing.T) { state := NewState() now := time.Now() threeMonthsAgo := now.Add(-90 * 24 * time.Hour) // Set up containers with the SAME VMID on DIFFERENT instances // This simulates: pve-1 has VMID 100 with old backup, pve-2 has newly created VMID 100 state.UpdateContainers([]Container{ {VMID: 100, Name: "old-container", Instance: "pve-1"}, {VMID: 100, Name: "new-container", Instance: "pve-2"}, // newly created, no backup }) // Add a 3-month-old backup for pve-1's container (VMID 100) state.mu.Lock() state.PVEBackups.StorageBackups = []StorageBackup{ {ID: "pve-1-backup-old", VMID: 100, Instance: "pve-1", Time: threeMonthsAgo}, } state.mu.Unlock() // Sync backup times state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() // Find both containers var oldContainer, newContainer *Container for i := range snapshot.Containers { if snapshot.Containers[i].Instance == "pve-1" && snapshot.Containers[i].VMID == 100 { oldContainer = &snapshot.Containers[i] } if snapshot.Containers[i].Instance == "pve-2" && snapshot.Containers[i].VMID == 100 { newContainer = &snapshot.Containers[i] } } if oldContainer == nil { t.Fatal("pve-1 container not found") } if newContainer == nil { t.Fatal("pve-2 container not found") } // The old container on pve-1 SHOULD have the backup time if !oldContainer.LastBackup.Equal(threeMonthsAgo) { t.Errorf("pve-1 container LastBackup = %v, expected %v", oldContainer.LastBackup, threeMonthsAgo) } // The NEW container on pve-2 should NOT have any backup time (it's a different container!) if !newContainer.LastBackup.IsZero() { t.Errorf("pve-2 container (newly created) should have no backup, got %v", newContainer.LastBackup) } } // TestSyncGuestBackupTimesNamespaceDisambiguation verifies that PBS namespace is used to // disambiguate backups when multiple VMs have the same VMID across different PVE instances. // This addresses issue #1095 where users have multiple PVE instances with overlapping VMIDs. func TestSyncGuestBackupTimesNamespaceDisambiguation(t *testing.T) { state := NewState() now := time.Now() pveBackupTime := now.Add(-1 * time.Hour) pveNatBackupTime := now.Add(-2 * time.Hour) // Set up VMs with the SAME VMID on DIFFERENT instances state.UpdateVMs([]VM{ {VMID: 100, Name: "webserver-pve", Instance: "pve", Node: "node1"}, {VMID: 100, Name: "webserver-nat", Instance: "pve-nat", Node: "node2"}, }) // Add PBS backups with namespaces that correspond to the PVE instances state.mu.Lock() state.PBSBackups = []PBSBackup{ { ID: "pbs-pve-100", VMID: "100", Namespace: "pve", BackupType: "vm", BackupTime: pveBackupTime, Instance: "pbs-main", }, { ID: "pbs-nat-100", VMID: "100", Namespace: "nat", // Should match "pve-nat" instance BackupType: "vm", BackupTime: pveNatBackupTime, Instance: "pbs-main", }, } state.mu.Unlock() // Sync backup times state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() // Find both VMs var vmPVE, vmNAT *VM for i := range snapshot.VMs { if snapshot.VMs[i].Instance == "pve" && snapshot.VMs[i].VMID == 100 { vmPVE = &snapshot.VMs[i] } if snapshot.VMs[i].Instance == "pve-nat" && snapshot.VMs[i].VMID == 100 { vmNAT = &snapshot.VMs[i] } } if vmPVE == nil { t.Fatal("pve VM not found") } if vmNAT == nil { t.Fatal("pve-nat VM not found") } // The pve VM should have the backup with namespace "pve" if !vmPVE.LastBackup.Equal(pveBackupTime) { t.Errorf("pve VM LastBackup = %v, expected %v (from namespace 'pve')", vmPVE.LastBackup, pveBackupTime) } // The pve-nat VM should have the backup with namespace "nat" if !vmNAT.LastBackup.Equal(pveNatBackupTime) { t.Errorf("pve-nat VM LastBackup = %v, expected %v (from namespace 'nat')", vmNAT.LastBackup, pveNatBackupTime) } } // TestSyncGuestBackupTimesVMIDCollisionNonMatchingNamespace verifies that when the same VMID // exists on multiple PVE instances and a PBS backup namespace matches neither, both guests // get zero LastBackup instead of a false positive. func TestSyncGuestBackupTimesVMIDCollisionNonMatchingNamespace(t *testing.T) { state := NewState() now := time.Now() backupTime := now.Add(-1 * time.Hour) // Two VMs with the same VMID on different instances state.UpdateVMs([]VM{ {VMID: 100, Name: "vm-pve1", Instance: "pve1", Node: "node1"}, {VMID: 100, Name: "vm-pve2", Instance: "pve2", Node: "node2"}, }) // PBS backup with a namespace that matches neither instance state.mu.Lock() state.PBSBackups = []PBSBackup{ { ID: "pbs-100", VMID: "100", Namespace: "staging", BackupType: "vm", BackupTime: backupTime, Instance: "pbs-main", }, } state.mu.Unlock() state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() for _, vm := range snapshot.VMs { if vm.VMID == 100 && !vm.LastBackup.IsZero() { t.Errorf("VM %q on %s should have zero LastBackup (ambiguous VMID, non-matching namespace), got %v", vm.Name, vm.Instance, vm.LastBackup) } } } // TestSyncGuestBackupTimesVMIDCollisionEmptyNamespace verifies that when the same VMID // exists on multiple PVE instances and a PBS backup has no namespace, both guests // get zero LastBackup. func TestSyncGuestBackupTimesVMIDCollisionEmptyNamespace(t *testing.T) { state := NewState() now := time.Now() backupTime := now.Add(-1 * time.Hour) state.UpdateVMs([]VM{ {VMID: 100, Name: "vm-pve1", Instance: "pve1", Node: "node1"}, {VMID: 100, Name: "vm-pve2", Instance: "pve2", Node: "node2"}, }) // PBS backup with empty namespace state.mu.Lock() state.PBSBackups = []PBSBackup{ { ID: "pbs-100", VMID: "100", Namespace: "", BackupType: "vm", BackupTime: backupTime, Instance: "pbs-main", }, } state.mu.Unlock() state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() for _, vm := range snapshot.VMs { if vm.VMID == 100 && !vm.LastBackup.IsZero() { t.Errorf("VM %q on %s should have zero LastBackup (ambiguous VMID, empty namespace), got %v", vm.Name, vm.Instance, vm.LastBackup) } } } // TestSyncGuestBackupTimesUniqueVMIDFallback verifies that a unique VMID still gets // the PBS backup via fallback even when the namespace doesn't match. func TestSyncGuestBackupTimesUniqueVMIDFallback(t *testing.T) { state := NewState() now := time.Now() backupTime := now.Add(-1 * time.Hour) // Single VM — VMID is unique state.UpdateVMs([]VM{ {VMID: 100, Name: "my-vm", Instance: "pve1", Node: "node1"}, }) // PBS backup with a namespace that does NOT match the instance state.mu.Lock() state.PBSBackups = []PBSBackup{ { ID: "pbs-100", VMID: "100", Namespace: "daily", BackupType: "vm", BackupTime: backupTime, Instance: "pbs-main", }, } state.mu.Unlock() state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() var found *VM for i := range snapshot.VMs { if snapshot.VMs[i].VMID == 100 { found = &snapshot.VMs[i] } } if found == nil { t.Fatal("VM not found") } if !found.LastBackup.Equal(backupTime) { t.Errorf("unique VMID should fall back to PBS backup; got LastBackup = %v, want %v", found.LastBackup, backupTime) } } // TestSyncGuestBackupTimesVMContainerCollision verifies that when a VM and Container // share the same VMID on different instances, neither gets an unmatched PBS backup. func TestSyncGuestBackupTimesVMContainerCollision(t *testing.T) { state := NewState() now := time.Now() backupTime := now.Add(-1 * time.Hour) state.UpdateVMs([]VM{ {VMID: 100, Name: "vm-pve1", Instance: "pve1", Node: "node1"}, }) state.UpdateContainers([]Container{ {VMID: 100, Name: "ct-pve2", Instance: "pve2", Node: "node2"}, }) // PBS backup with namespace matching neither state.mu.Lock() state.PBSBackups = []PBSBackup{ { ID: "pbs-100", VMID: "100", Namespace: "other", BackupType: "vm", BackupTime: backupTime, Instance: "pbs-main", }, } state.mu.Unlock() state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() for _, vm := range snapshot.VMs { if vm.VMID == 100 && !vm.LastBackup.IsZero() { t.Errorf("VM %q should have zero LastBackup (ambiguous VMID with container), got %v", vm.Name, vm.LastBackup) } } for _, ct := range snapshot.Containers { if ct.VMID == 100 && !ct.LastBackup.IsZero() { t.Errorf("Container %q should have zero LastBackup (ambiguous VMID with VM), got %v", ct.Name, ct.LastBackup) } } } func TestUpdateStorageBackupsForInstance(t *testing.T) { state := NewState() now := time.Now() // Set up a VM so node normalization has something to work with state.UpdateVMsForInstance("pve-1", []VM{ {VMID: 100, Instance: "pve-1", Node: "node1"}, }) // Add backups from first instance backups1 := []StorageBackup{ {ID: "pve-1-backup-1", Instance: "pve-1", VMID: 100, Time: now, Node: "node1"}, {ID: "pve-1-backup-2", Instance: "pve-1", VMID: 100, Time: now.Add(-time.Hour), Node: "node1"}, } state.UpdateStorageBackupsForInstance("pve-1", backups1) snapshot := state.GetSnapshot() if len(snapshot.PVEBackups.StorageBackups) != 2 { t.Fatalf("Expected 2 backups, got %d", len(snapshot.PVEBackups.StorageBackups)) } // Backups should be sorted by time descending if snapshot.PVEBackups.StorageBackups[0].Time.Before(snapshot.PVEBackups.StorageBackups[1].Time) { t.Error("Backups should be sorted by time descending") } // Add backups from second instance backups2 := []StorageBackup{ {ID: "pve-2-backup-1", Instance: "pve-2", VMID: 200, Time: now.Add(-30 * time.Minute), Node: "node2"}, } state.UpdateStorageBackupsForInstance("pve-2", backups2) snapshot = state.GetSnapshot() if len(snapshot.PVEBackups.StorageBackups) != 3 { t.Fatalf("Expected 3 backups, got %d", len(snapshot.PVEBackups.StorageBackups)) } // Update first instance (should replace its backups) backups1Updated := []StorageBackup{ {ID: "pve-1-backup-3", Instance: "pve-1", VMID: 100, Time: now.Add(time.Hour), Node: "node1"}, } state.UpdateStorageBackupsForInstance("pve-1", backups1Updated) snapshot = state.GetSnapshot() if len(snapshot.PVEBackups.StorageBackups) != 2 { t.Fatalf("Expected 2 backups after update, got %d", len(snapshot.PVEBackups.StorageBackups)) } // Verify old pve-1 backups were replaced for _, b := range snapshot.PVEBackups.StorageBackups { if b.ID == "pve-1-backup-1" || b.ID == "pve-1-backup-2" { t.Error("Old pve-1 backups should have been replaced") } } }