Files
grafana/pkg/registry/apis/provisioning/controller/health_test.go
T
Stephanie Hingtgen 9ddc70423b Provisioning: Cleanup tester interface (#110640)
* Provisioning: Cleanup tester interface

* undo accidental change

* cleanup

* cleanup test
2025-09-05 07:47:27 +02:00

591 lines
15 KiB
Go

package controller
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller/mocks"
)
func TestNewHealthChecker(t *testing.T) {
mockPatcher := mocks.NewStatusPatcher(t)
hc := NewHealthChecker(mockPatcher)
assert.NotNil(t, hc)
assert.Equal(t, mockPatcher, hc.statusPatcher)
}
func TestShouldCheckHealth(t *testing.T) {
tests := []struct {
name string
repo *provisioning.Repository
expected bool
}{
{
name: "should check when generation differs",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Generation: 2},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().UnixMilli(),
},
},
},
expected: true,
},
{
name: "should not check when hook error exists",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Generation: 1},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHook,
Checked: time.Now().UnixMilli(),
},
},
},
expected: false,
},
{
name: "should not check when health check is recent and healthy",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Generation: 1},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Minute * 3).UnixMilli(), // 3 minutes ago
},
},
},
expected: false,
},
{
name: "should check when health check is old and healthy",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Generation: 1},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Minute * 6).UnixMilli(), // 6 minutes ago
},
},
},
expected: true,
},
{
name: "should not check when health error is recent",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Generation: 1},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHealth,
Checked: time.Now().Add(-time.Second * 30).UnixMilli(), // 30 seconds ago
},
},
},
expected: false,
},
{
name: "should check when health error is old",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Generation: 1},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHealth,
Checked: time.Now().Add(-time.Minute * 2).UnixMilli(), // 2 minutes ago
},
},
},
expected: true,
},
{
name: "should check when never checked",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{Generation: 1},
Status: provisioning.RepositoryStatus{
ObservedGeneration: 1,
Health: provisioning.HealthStatus{
Checked: 0, // Never checked
},
},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockPatcher := mocks.NewStatusPatcher(t)
hc := NewHealthChecker(mockPatcher)
result := hc.ShouldCheckHealth(tt.repo)
assert.Equal(t, tt.expected, result)
})
}
}
func TestHasRecentFailure(t *testing.T) {
tests := []struct {
name string
healthStatus provisioning.HealthStatus
failureType provisioning.HealthFailureType
expected bool
}{
{
name: "no recent failure when never checked",
healthStatus: provisioning.HealthStatus{
Checked: 0,
},
failureType: provisioning.HealthFailureHook,
expected: false,
},
{
name: "recent hook failure",
healthStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHook,
Checked: time.Now().Add(-time.Second * 30).UnixMilli(),
},
failureType: provisioning.HealthFailureHook,
expected: true,
},
{
name: "old hook failure",
healthStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHook,
Checked: time.Now().Add(-time.Minute * 2).UnixMilli(),
},
failureType: provisioning.HealthFailureHook,
expected: false,
},
{
name: "recent health failure",
healthStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHealth,
Checked: time.Now().Add(-time.Second * 30).UnixMilli(),
},
failureType: provisioning.HealthFailureHealth,
expected: true,
},
{
name: "old health failure",
healthStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHealth,
Checked: time.Now().Add(-time.Minute * 2).UnixMilli(),
},
failureType: provisioning.HealthFailureHealth,
expected: false,
},
{
name: "wrong failure type",
healthStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHook,
Checked: time.Now().Add(-time.Second * 30).UnixMilli(),
},
failureType: provisioning.HealthFailureHealth,
expected: false,
},
{
name: "healthy status with wrong failure type",
healthStatus: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Second * 30).UnixMilli(),
},
failureType: provisioning.HealthFailureHook,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockPatcher := mocks.NewStatusPatcher(t)
hc := NewHealthChecker(mockPatcher)
result := hc.HasRecentFailure(tt.healthStatus, tt.failureType)
assert.Equal(t, tt.expected, result)
})
}
}
func TestRecordFailure(t *testing.T) {
tests := []struct {
name string
failureType provisioning.HealthFailureType
err error
patchError error
expectError bool
}{
{
name: "successful hook failure record",
failureType: provisioning.HealthFailureHook,
err: errors.New("hook failed"),
patchError: nil,
expectError: false,
},
{
name: "successful health failure record",
failureType: provisioning.HealthFailureHealth,
err: errors.New("health check failed"),
patchError: nil,
expectError: false,
},
{
name: "patch failure",
failureType: provisioning.HealthFailureHook,
err: errors.New("hook failed"),
patchError: errors.New("patch failed"),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockPatcher := mocks.NewStatusPatcher(t)
hc := NewHealthChecker(mockPatcher)
repo := &provisioning.Repository{
Status: provisioning.RepositoryStatus{
Health: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Hour).UnixMilli(),
},
},
}
if tt.patchError != nil {
mockPatcher.On("Patch", mock.Anything, repo, mock.AnythingOfType("map[string]interface {}")).
Return(tt.patchError)
} else {
mockPatcher.On("Patch", mock.Anything, repo, mock.AnythingOfType("map[string]interface {}")).
Return(nil).
Run(func(args mock.Arguments) {
patchOp := args[2].(map[string]interface{})
assert.Equal(t, "replace", patchOp["op"])
assert.Equal(t, "/status/health", patchOp["path"])
healthStatus := patchOp["value"].(provisioning.HealthStatus)
assert.False(t, healthStatus.Healthy)
assert.Equal(t, tt.failureType, healthStatus.Error)
assert.Contains(t, healthStatus.Message, tt.err.Error())
assert.Greater(t, healthStatus.Checked, int64(0))
})
}
err := hc.RecordFailure(context.Background(), tt.failureType, tt.err, repo)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
mockPatcher.AssertExpectations(t)
})
}
}
func TestRecordFailureFunction(t *testing.T) {
mockPatcher := mocks.NewStatusPatcher(t)
hc := NewHealthChecker(mockPatcher)
testErr := errors.New("test error")
result := hc.recordFailure(provisioning.HealthFailureHook, testErr)
assert.False(t, result.Healthy)
assert.Equal(t, provisioning.HealthFailureHook, result.Error)
assert.Equal(t, []string{"test error"}, result.Message)
assert.Greater(t, result.Checked, int64(0))
}
func TestRefreshHealth(t *testing.T) {
tests := []struct {
name string
testResult *provisioning.TestResults
testError error
patchError error
existingStatus provisioning.HealthStatus
expectError bool
expectedHealth bool
expectPatch bool
}{
{
name: "successful health check",
testResult: &provisioning.TestResults{
Success: true,
Code: 200,
},
testError: nil,
existingStatus: provisioning.HealthStatus{
Healthy: false,
Error: provisioning.HealthFailureHealth,
Checked: time.Now().Add(-time.Hour).UnixMilli(),
},
expectError: false,
expectedHealth: true,
expectPatch: true,
},
{
name: "failed health check",
testResult: &provisioning.TestResults{
Success: false,
Code: 500,
Errors: []provisioning.ErrorDetails{
{Detail: "connection failed"},
{Detail: "timeout"},
},
},
testError: nil,
existingStatus: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Hour).UnixMilli(),
},
expectError: false,
expectedHealth: false,
expectPatch: true,
},
{
name: "test repository error",
testResult: nil,
testError: errors.New("repository test failed"),
existingStatus: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Hour).UnixMilli(),
},
expectError: true,
expectedHealth: false,
expectPatch: false,
},
{
name: "no status change - no patch needed (recent check)",
testResult: &provisioning.TestResults{
Success: true,
Code: 200,
},
testError: nil,
existingStatus: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-15 * time.Second).UnixMilli(),
},
expectError: false,
expectedHealth: true,
expectPatch: false,
},
{
name: "status unchanged but timestamp needs update (old check)",
testResult: &provisioning.TestResults{
Success: true,
Code: 200,
},
testError: nil,
existingStatus: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Hour).UnixMilli(),
},
expectError: false,
expectedHealth: true,
expectPatch: true,
},
{
name: "patch error",
testResult: &provisioning.TestResults{
Success: false,
Code: 500,
Errors: []provisioning.ErrorDetails{
{Detail: "connection failed"},
},
},
testError: nil,
patchError: errors.New("patch failed"),
existingStatus: provisioning.HealthStatus{
Healthy: true,
Checked: time.Now().Add(-time.Hour).UnixMilli(),
},
expectError: true,
expectedHealth: false,
expectPatch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockPatcher := mocks.NewStatusPatcher(t)
mockRepo := &mockRepository{
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repository",
Type: provisioning.LocalRepositoryType,
},
Status: provisioning.RepositoryStatus{
Health: tt.existingStatus,
},
},
testResult: tt.testResult,
testError: tt.testError,
}
hc := NewHealthChecker(mockPatcher)
if tt.expectPatch {
if tt.patchError != nil {
mockPatcher.On("Patch", mock.Anything, mockRepo.config, mock.AnythingOfType("map[string]interface {}")).
Return(tt.patchError)
} else {
mockPatcher.On("Patch", mock.Anything, mockRepo.config, mock.AnythingOfType("map[string]interface {}")).
Return(nil).
Run(func(args mock.Arguments) {
patchOp := args[2].(map[string]interface{})
assert.Equal(t, "replace", patchOp["op"])
assert.Equal(t, "/status/health", patchOp["path"])
healthStatus := patchOp["value"].(provisioning.HealthStatus)
assert.Equal(t, tt.expectedHealth, healthStatus.Healthy)
})
}
}
testResult, healthStatus, err := hc.RefreshHealth(context.Background(), mockRepo)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedHealth, healthStatus.Healthy)
if tt.testResult != nil {
assert.Equal(t, tt.testResult, testResult)
}
}
if tt.expectPatch {
mockPatcher.AssertExpectations(t)
}
})
}
}
func TestHasHealthStatusChanged(t *testing.T) {
tests := []struct {
name string
old provisioning.HealthStatus
new provisioning.HealthStatus
expected bool
}{
{
name: "healthy status changed",
old: provisioning.HealthStatus{
Healthy: true,
Message: []string{},
},
new: provisioning.HealthStatus{
Healthy: false,
Message: []string{"error"},
},
expected: true,
},
{
name: "different message count",
old: provisioning.HealthStatus{
Healthy: false,
Message: []string{"error1"},
},
new: provisioning.HealthStatus{
Healthy: false,
Message: []string{"error1", "error2"},
},
expected: true,
},
{
name: "different messages",
old: provisioning.HealthStatus{
Healthy: false,
Message: []string{"error1"},
},
new: provisioning.HealthStatus{
Healthy: false,
Message: []string{"error2"},
},
expected: true,
},
{
name: "no change",
old: provisioning.HealthStatus{
Healthy: true,
Message: []string{},
},
new: provisioning.HealthStatus{
Healthy: true,
Message: []string{},
},
expected: false,
},
{
name: "same messages",
old: provisioning.HealthStatus{
Healthy: false,
Message: []string{"error1", "error2"},
},
new: provisioning.HealthStatus{
Healthy: false,
Message: []string{"error1", "error2"},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockPatcher := mocks.NewStatusPatcher(t)
hc := NewHealthChecker(mockPatcher)
result := hc.hasHealthStatusChanged(tt.old, tt.new)
assert.Equal(t, tt.expected, result)
})
}
}
// mockRepository implements repository.Repository interface for testing
type mockRepository struct {
config *provisioning.Repository
testResult *provisioning.TestResults
testError error
}
func (m *mockRepository) Config() *provisioning.Repository {
return m.config
}
func (m *mockRepository) Validate() field.ErrorList {
return nil
}
func (m *mockRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
if m.testError != nil {
return m.testResult, m.testError
}
if m.testResult != nil {
return m.testResult, nil
}
return &provisioning.TestResults{Success: true, Code: 200}, nil
}