test: Add tests for storageNamesForNode, GetDockerHost, and SchedulerHealth edge cases
- storageNamesForNode: 83.3%→100% (8 cases for filtering logic) - GetDockerHost: 75%→100% (5 cases for lookup and whitespace handling) - SchedulerHealth: 85.3%→89.1% (13 cases for nil handling, breaker key parsing)
This commit is contained in:
parent
2d775e9409
commit
0bfc9ed447
2 changed files with 396 additions and 26 deletions
|
|
@ -1273,6 +1273,85 @@ func TestRemoveFailedPBSNode(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetDockerHost(t *testing.T) {
|
||||||
|
t.Run("empty hostID returns empty host and false", func(t *testing.T) {
|
||||||
|
state := models.NewState()
|
||||||
|
state.UpsertDockerHost(models.DockerHost{ID: "host1", Hostname: "docker1"})
|
||||||
|
m := &Monitor{state: state}
|
||||||
|
|
||||||
|
host, found := m.GetDockerHost("")
|
||||||
|
if found {
|
||||||
|
t.Error("expected found to be false for empty hostID")
|
||||||
|
}
|
||||||
|
if host.ID != "" {
|
||||||
|
t.Errorf("expected empty host, got ID=%q", host.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("whitespace-only hostID returns empty host and false", func(t *testing.T) {
|
||||||
|
state := models.NewState()
|
||||||
|
state.UpsertDockerHost(models.DockerHost{ID: "host1", Hostname: "docker1"})
|
||||||
|
m := &Monitor{state: state}
|
||||||
|
|
||||||
|
host, found := m.GetDockerHost(" ")
|
||||||
|
if found {
|
||||||
|
t.Error("expected found to be false for whitespace-only hostID")
|
||||||
|
}
|
||||||
|
if host.ID != "" {
|
||||||
|
t.Errorf("expected empty host, got ID=%q", host.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("host ID not found returns empty host and false", func(t *testing.T) {
|
||||||
|
state := models.NewState()
|
||||||
|
state.UpsertDockerHost(models.DockerHost{ID: "host1", Hostname: "docker1"})
|
||||||
|
m := &Monitor{state: state}
|
||||||
|
|
||||||
|
host, found := m.GetDockerHost("nonexistent")
|
||||||
|
if found {
|
||||||
|
t.Error("expected found to be false for nonexistent hostID")
|
||||||
|
}
|
||||||
|
if host.ID != "" {
|
||||||
|
t.Errorf("expected empty host, got ID=%q", host.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("host ID found returns the host and true", func(t *testing.T) {
|
||||||
|
state := models.NewState()
|
||||||
|
state.UpsertDockerHost(models.DockerHost{ID: "host1", Hostname: "docker1"})
|
||||||
|
state.UpsertDockerHost(models.DockerHost{ID: "host2", Hostname: "docker2"})
|
||||||
|
m := &Monitor{state: state}
|
||||||
|
|
||||||
|
host, found := m.GetDockerHost("host1")
|
||||||
|
if !found {
|
||||||
|
t.Error("expected found to be true")
|
||||||
|
}
|
||||||
|
if host.ID != "host1" {
|
||||||
|
t.Errorf("expected ID='host1', got %q", host.ID)
|
||||||
|
}
|
||||||
|
if host.Hostname != "docker1" {
|
||||||
|
t.Errorf("expected Hostname='docker1', got %q", host.Hostname)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hostID with leading/trailing whitespace is trimmed and found", func(t *testing.T) {
|
||||||
|
state := models.NewState()
|
||||||
|
state.UpsertDockerHost(models.DockerHost{ID: "host1", Hostname: "docker1"})
|
||||||
|
m := &Monitor{state: state}
|
||||||
|
|
||||||
|
host, found := m.GetDockerHost(" host1 ")
|
||||||
|
if !found {
|
||||||
|
t.Error("expected found to be true after trimming whitespace")
|
||||||
|
}
|
||||||
|
if host.ID != "host1" {
|
||||||
|
t.Errorf("expected ID='host1', got %q", host.ID)
|
||||||
|
}
|
||||||
|
if host.Hostname != "docker1" {
|
||||||
|
t.Errorf("expected Hostname='docker1', got %q", host.Hostname)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveFailedPMGInstance(t *testing.T) {
|
func TestRemoveFailedPMGInstance(t *testing.T) {
|
||||||
t.Run("removes correct instance from PMGInstances", func(t *testing.T) {
|
t.Run("removes correct instance from PMGInstances", func(t *testing.T) {
|
||||||
state := models.NewState()
|
state := models.NewState()
|
||||||
|
|
@ -1377,3 +1456,224 @@ func TestRemoveFailedPMGInstance(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchedulerHealth(t *testing.T) {
|
||||||
|
t.Run("nil config returns Enabled false", func(t *testing.T) {
|
||||||
|
m := &Monitor{config: nil}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
if resp.Enabled {
|
||||||
|
t.Error("expected Enabled to be false when config is nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("config with AdaptivePollingEnabled false returns Enabled false", func(t *testing.T) {
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{AdaptivePollingEnabled: false},
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
if resp.Enabled {
|
||||||
|
t.Error("expected Enabled to be false when AdaptivePollingEnabled is false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("config with AdaptivePollingEnabled true returns Enabled true", func(t *testing.T) {
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{AdaptivePollingEnabled: true},
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
if !resp.Enabled {
|
||||||
|
t.Error("expected Enabled to be true when AdaptivePollingEnabled is true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil taskQueue returns empty Queue", func(t *testing.T) {
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
taskQueue: nil,
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
// Queue should be zero value (empty)
|
||||||
|
if resp.Queue.Depth != 0 {
|
||||||
|
t.Errorf("expected Queue.Depth to be 0, got %d", resp.Queue.Depth)
|
||||||
|
}
|
||||||
|
if len(resp.Queue.PerType) != 0 {
|
||||||
|
t.Errorf("expected Queue.PerType to be empty, got %d entries", len(resp.Queue.PerType))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-nil taskQueue returns queue snapshot", func(t *testing.T) {
|
||||||
|
tq := NewTaskQueue()
|
||||||
|
tq.Upsert(ScheduledTask{
|
||||||
|
InstanceType: InstanceTypePVE,
|
||||||
|
InstanceName: "pve1",
|
||||||
|
NextRun: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
taskQueue: tq,
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
if resp.Queue.Depth != 1 {
|
||||||
|
t.Errorf("expected Queue.Depth to be 1, got %d", resp.Queue.Depth)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil deadLetterQueue returns empty DeadLetter", func(t *testing.T) {
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
deadLetterQueue: nil,
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
if resp.DeadLetter.Count != 0 {
|
||||||
|
t.Errorf("expected DeadLetter.Count to be 0, got %d", resp.DeadLetter.Count)
|
||||||
|
}
|
||||||
|
if len(resp.DeadLetter.Tasks) != 0 {
|
||||||
|
t.Errorf("expected DeadLetter.Tasks to be empty, got %d tasks", len(resp.DeadLetter.Tasks))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-nil deadLetterQueue returns dead letter snapshot", func(t *testing.T) {
|
||||||
|
dlq := NewTaskQueue()
|
||||||
|
dlq.Upsert(ScheduledTask{
|
||||||
|
InstanceType: InstanceTypePVE,
|
||||||
|
InstanceName: "failed-pve",
|
||||||
|
NextRun: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
deadLetterQueue: dlq,
|
||||||
|
lastOutcome: make(map[string]taskOutcome),
|
||||||
|
failureCounts: make(map[string]int),
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
if resp.DeadLetter.Count != 1 {
|
||||||
|
t.Errorf("expected DeadLetter.Count to be 1, got %d", resp.DeadLetter.Count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("circuit breaker key with :: separator extracts type and name", func(t *testing.T) {
|
||||||
|
breaker := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second)
|
||||||
|
// Record a failure to make the breaker appear in the response
|
||||||
|
breaker.recordFailure(time.Now())
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
circuitBreakers: map[string]*circuitBreaker{
|
||||||
|
"pve::my-node": breaker,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
|
||||||
|
if len(resp.Breakers) != 1 {
|
||||||
|
t.Fatalf("expected 1 breaker, got %d", len(resp.Breakers))
|
||||||
|
}
|
||||||
|
if resp.Breakers[0].Type != "pve" {
|
||||||
|
t.Errorf("expected breaker Type to be 'pve', got %q", resp.Breakers[0].Type)
|
||||||
|
}
|
||||||
|
if resp.Breakers[0].Instance != "my-node" {
|
||||||
|
t.Errorf("expected breaker Instance to be 'my-node', got %q", resp.Breakers[0].Instance)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("circuit breaker key without :: separator uses unknown type", func(t *testing.T) {
|
||||||
|
breaker := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second)
|
||||||
|
breaker.recordFailure(time.Now())
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
circuitBreakers: map[string]*circuitBreaker{
|
||||||
|
"legacy-key-no-separator": breaker,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
|
||||||
|
if len(resp.Breakers) != 1 {
|
||||||
|
t.Fatalf("expected 1 breaker, got %d", len(resp.Breakers))
|
||||||
|
}
|
||||||
|
if resp.Breakers[0].Type != "unknown" {
|
||||||
|
t.Errorf("expected breaker Type to be 'unknown', got %q", resp.Breakers[0].Type)
|
||||||
|
}
|
||||||
|
if resp.Breakers[0].Instance != "legacy-key-no-separator" {
|
||||||
|
t.Errorf("expected breaker Instance to be 'legacy-key-no-separator', got %q", resp.Breakers[0].Instance)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("circuit breaker in closed state with 0 failures is skipped", func(t *testing.T) {
|
||||||
|
// A freshly created breaker is closed with 0 failures
|
||||||
|
breaker := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second)
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
circuitBreakers: map[string]*circuitBreaker{
|
||||||
|
"pve::healthy-node": breaker,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
|
||||||
|
if len(resp.Breakers) != 0 {
|
||||||
|
t.Errorf("expected 0 breakers (closed with 0 failures should be skipped), got %d", len(resp.Breakers))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("open breaker is included regardless of failure count", func(t *testing.T) {
|
||||||
|
breaker := newCircuitBreaker(1, 5*time.Second, 5*time.Minute, 30*time.Second)
|
||||||
|
// Trip the breaker open by recording enough failures
|
||||||
|
breaker.recordFailure(time.Now())
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
circuitBreakers: map[string]*circuitBreaker{
|
||||||
|
"pbs::failed-backup": breaker,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
|
||||||
|
if len(resp.Breakers) != 1 {
|
||||||
|
t.Fatalf("expected 1 breaker, got %d", len(resp.Breakers))
|
||||||
|
}
|
||||||
|
if resp.Breakers[0].State != "open" {
|
||||||
|
t.Errorf("expected breaker State to be 'open', got %q", resp.Breakers[0].State)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple breakers with mixed states", func(t *testing.T) {
|
||||||
|
healthyBreaker := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second)
|
||||||
|
|
||||||
|
failingBreaker := newCircuitBreaker(3, 5*time.Second, 5*time.Minute, 30*time.Second)
|
||||||
|
failingBreaker.recordFailure(time.Now())
|
||||||
|
|
||||||
|
tripBreaker := newCircuitBreaker(1, 5*time.Second, 5*time.Minute, 30*time.Second)
|
||||||
|
tripBreaker.recordFailure(time.Now())
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
config: &config.Config{},
|
||||||
|
circuitBreakers: map[string]*circuitBreaker{
|
||||||
|
"pve::healthy": healthyBreaker,
|
||||||
|
"pve::failing": failingBreaker,
|
||||||
|
"pbs::tripped": tripBreaker,
|
||||||
|
"legacy-no-colon": failingBreaker, // shares same breaker but different key
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
|
||||||
|
// healthyBreaker (closed, 0 failures) should be excluded
|
||||||
|
// failingBreaker (closed, 1 failure) should be included twice (pve::failing and legacy-no-colon)
|
||||||
|
// tripBreaker (open, 1 failure) should be included
|
||||||
|
if len(resp.Breakers) != 3 {
|
||||||
|
t.Errorf("expected 3 breakers (excluding healthy), got %d", len(resp.Breakers))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdatedAt is set to current time", func(t *testing.T) {
|
||||||
|
before := time.Now()
|
||||||
|
m := &Monitor{config: &config.Config{}}
|
||||||
|
resp := m.SchedulerHealth()
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
if resp.UpdatedAt.Before(before) || resp.UpdatedAt.After(after) {
|
||||||
|
t.Errorf("expected UpdatedAt between %v and %v, got %v", before, after, resp.UpdatedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,37 +93,107 @@ func TestPreserveFailedStorageBackupsSkipsDuplicates(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStorageNamesForNode(t *testing.T) {
|
func TestStorageNamesForNode(t *testing.T) {
|
||||||
instance := "pve01"
|
tests := []struct {
|
||||||
snapshot := models.StateSnapshot{
|
name string
|
||||||
Storage: []models.Storage{
|
instanceName string
|
||||||
{
|
nodeName string
|
||||||
Name: "local-zfs",
|
snapshot models.StateSnapshot
|
||||||
Instance: instance,
|
want []string
|
||||||
Node: "pve-node1",
|
}{
|
||||||
Content: "images,rootdir,backup",
|
{
|
||||||
|
name: "empty nodeName returns nil",
|
||||||
|
instanceName: "pve1",
|
||||||
|
nodeName: "",
|
||||||
|
snapshot: models.StateSnapshot{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Instance: "pve1", Name: "local-backup", Node: "node1", Content: "backup"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
want: nil,
|
||||||
Name: "nas-share",
|
},
|
||||||
Instance: instance,
|
{
|
||||||
Nodes: []string{"pve-node2", "pve-node3"},
|
name: "empty snapshot returns nil",
|
||||||
Content: "backup,iso",
|
instanceName: "pve1",
|
||||||
|
nodeName: "node1",
|
||||||
|
snapshot: models.StateSnapshot{},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage with wrong instance is skipped",
|
||||||
|
instanceName: "pve1",
|
||||||
|
nodeName: "node1",
|
||||||
|
snapshot: models.StateSnapshot{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Instance: "pve2", Name: "local-backup", Node: "node1", Content: "backup"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
want: nil,
|
||||||
Name: "other-instance",
|
},
|
||||||
Instance: "pve02",
|
{
|
||||||
Node: "pve-node1",
|
name: "storage with empty name is skipped",
|
||||||
Content: "backup",
|
instanceName: "pve1",
|
||||||
|
nodeName: "node1",
|
||||||
|
snapshot: models.StateSnapshot{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Instance: "pve1", Name: "", Node: "node1", Content: "backup"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage without backup in Content is skipped",
|
||||||
|
instanceName: "pve1",
|
||||||
|
nodeName: "node1",
|
||||||
|
snapshot: models.StateSnapshot{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Instance: "pve1", Name: "local", Node: "node1", Content: "images,rootdir"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage where Node matches nodeName is included",
|
||||||
|
instanceName: "pve1",
|
||||||
|
nodeName: "node1",
|
||||||
|
snapshot: models.StateSnapshot{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Instance: "pve1", Name: "backup-storage", Node: "node1", Content: "backup"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []string{"backup-storage"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage where nodeName is in Nodes slice is included",
|
||||||
|
instanceName: "pve1",
|
||||||
|
nodeName: "node2",
|
||||||
|
snapshot: models.StateSnapshot{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Instance: "pve1", Name: "shared-backup", Node: "node1", Nodes: []string{"node1", "node2", "node3"}, Content: "backup"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []string{"shared-backup"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple matching storages are returned",
|
||||||
|
instanceName: "pve1",
|
||||||
|
nodeName: "node1",
|
||||||
|
snapshot: models.StateSnapshot{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Instance: "pve1", Name: "local-backup", Node: "node1", Content: "backup"},
|
||||||
|
{Instance: "pve1", Name: "nfs-backup", Node: "node1", Content: "backup,images"},
|
||||||
|
{Instance: "pve1", Name: "shared-backup", Node: "node2", Nodes: []string{"node1", "node2"}, Content: "backup"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: []string{"local-backup", "nfs-backup", "shared-backup"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
found := storageNamesForNode(instance, "pve-node2", snapshot)
|
for _, tt := range tests {
|
||||||
if !slices.Equal(found, []string{"nas-share"}) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Fatalf("unexpected storages for node2: %v", found)
|
got := storageNamesForNode(tt.instanceName, tt.nodeName, tt.snapshot)
|
||||||
}
|
if !slices.Equal(got, tt.want) {
|
||||||
|
t.Errorf("storageNamesForNode() = %v, want %v", got, tt.want)
|
||||||
found = storageNamesForNode(instance, "pve-node1", snapshot)
|
}
|
||||||
if !slices.Equal(found, []string{"local-zfs"}) {
|
})
|
||||||
t.Fatalf("unexpected storages for node1: %v", found)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue