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) {
|
||||
t.Run("removes correct instance from PMGInstances", func(t *testing.T) {
|
||||
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) {
|
||||
instance := "pve01"
|
||||
snapshot := models.StateSnapshot{
|
||||
Storage: []models.Storage{
|
||||
{
|
||||
Name: "local-zfs",
|
||||
Instance: instance,
|
||||
Node: "pve-node1",
|
||||
Content: "images,rootdir,backup",
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceName string
|
||||
nodeName string
|
||||
snapshot models.StateSnapshot
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty nodeName returns nil",
|
||||
instanceName: "pve1",
|
||||
nodeName: "",
|
||||
snapshot: models.StateSnapshot{
|
||||
Storage: []models.Storage{
|
||||
{Instance: "pve1", Name: "local-backup", Node: "node1", Content: "backup"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "nas-share",
|
||||
Instance: instance,
|
||||
Nodes: []string{"pve-node2", "pve-node3"},
|
||||
Content: "backup,iso",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty snapshot returns nil",
|
||||
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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "other-instance",
|
||||
Instance: "pve02",
|
||||
Node: "pve-node1",
|
||||
Content: "backup",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "storage with empty name is skipped",
|
||||
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)
|
||||
if !slices.Equal(found, []string{"nas-share"}) {
|
||||
t.Fatalf("unexpected storages for node2: %v", found)
|
||||
}
|
||||
|
||||
found = storageNamesForNode(instance, "pve-node1", snapshot)
|
||||
if !slices.Equal(found, []string{"local-zfs"}) {
|
||||
t.Fatalf("unexpected storages for node1: %v", found)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := storageNamesForNode(tt.instanceName, tt.nodeName, tt.snapshot)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("storageNamesForNode() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue