package service import ( "context" "errors" "log/slog" "os" "testing" "time" "github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/repository" ) // mockHealthCheckRepo implements the HealthCheckRepository interface for testing. type mockHealthCheckRepo struct { checks map[string]*domain.EndpointHealthCheck history []*domain.HealthHistoryEntry createErr error getErr error updateErr error deleteErr error listErr error listDueErr error getHistoryErr error recordHistoryErr error purgeHistoryErr error getSummaryErr error getSummaryResult *domain.HealthCheckSummary } func newMockHealthCheckRepo() *mockHealthCheckRepo { return &mockHealthCheckRepo{ checks: make(map[string]*domain.EndpointHealthCheck), history: []*domain.HealthHistoryEntry{}, getSummaryResult: &domain.HealthCheckSummary{ Healthy: 0, Degraded: 0, Down: 0, CertMismatch: 0, Unknown: 0, }, } } func (m *mockHealthCheckRepo) Create(ctx context.Context, check *domain.EndpointHealthCheck) error { if m.createErr != nil { return m.createErr } m.checks[check.ID] = check return nil } func (m *mockHealthCheckRepo) Get(ctx context.Context, id string) (*domain.EndpointHealthCheck, error) { if m.getErr != nil { return nil, m.getErr } if check, ok := m.checks[id]; ok { return check, nil } return nil, errors.New("not found") } func (m *mockHealthCheckRepo) GetByEndpoint(ctx context.Context, endpoint string) (*domain.EndpointHealthCheck, error) { for _, check := range m.checks { if check.Endpoint == endpoint { return check, nil } } return nil, errors.New("not found") } func (m *mockHealthCheckRepo) Update(ctx context.Context, check *domain.EndpointHealthCheck) error { if m.updateErr != nil { return m.updateErr } m.checks[check.ID] = check return nil } func (m *mockHealthCheckRepo) Delete(ctx context.Context, id string) error { if m.deleteErr != nil { return m.deleteErr } delete(m.checks, id) return nil } func (m *mockHealthCheckRepo) List(ctx context.Context, filter *repository.HealthCheckFilter) ([]*domain.EndpointHealthCheck, int, error) { if m.listErr != nil { return nil, 0, m.listErr } checks := make([]*domain.EndpointHealthCheck, 0, len(m.checks)) for _, check := range m.checks { checks = append(checks, check) } return checks, len(checks), nil } func (m *mockHealthCheckRepo) ListDueForCheck(ctx context.Context) ([]*domain.EndpointHealthCheck, error) { if m.listDueErr != nil { return nil, m.listDueErr } checks := make([]*domain.EndpointHealthCheck, 0, len(m.checks)) for _, check := range m.checks { if check.Enabled { checks = append(checks, check) } } return checks, nil } func (m *mockHealthCheckRepo) GetHistory(ctx context.Context, healthCheckID string, limit int) ([]*domain.HealthHistoryEntry, error) { if m.getHistoryErr != nil { return nil, m.getHistoryErr } return m.history, nil } func (m *mockHealthCheckRepo) RecordHistory(ctx context.Context, entry *domain.HealthHistoryEntry) error { if m.recordHistoryErr != nil { return m.recordHistoryErr } m.history = append(m.history, entry) return nil } func (m *mockHealthCheckRepo) PurgeHistory(ctx context.Context, before time.Time) (int64, error) { if m.purgeHistoryErr != nil { return 0, m.purgeHistoryErr } return 0, nil } func (m *mockHealthCheckRepo) GetSummary(ctx context.Context) (*domain.HealthCheckSummary, error) { if m.getSummaryErr != nil { return nil, m.getSummaryErr } return m.getSummaryResult, nil } // Tests func newTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) } func TestHealthCheckService_Create_Success(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) check := &domain.EndpointHealthCheck{ Endpoint: "example.com:443", Status: domain.HealthStatusUnknown, Enabled: true, CheckIntervalSecs: 300, } err := svc.Create(context.Background(), check) if err != nil { t.Fatalf("Create failed: %v", err) } if check.ID == "" { t.Fatal("Expected ID to be set") } retrieved, _ := repo.Get(context.Background(), check.ID) if retrieved == nil { t.Fatal("Expected check to be in repo") } if retrieved.Endpoint != "example.com:443" { t.Errorf("Expected endpoint example.com:443, got %s", retrieved.Endpoint) } } func TestHealthCheckService_Create_RepoError(t *testing.T) { repo := newMockHealthCheckRepo() repo.createErr = errors.New("db error") logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) check := &domain.EndpointHealthCheck{ Endpoint: "example.com:443", Enabled: true, } err := svc.Create(context.Background(), check) if err == nil { t.Fatal("Expected error, got nil") } } func TestHealthCheckService_Get_Success(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) check := &domain.EndpointHealthCheck{ ID: "hc-test-1", Endpoint: "example.com:443", Status: domain.HealthStatusHealthy, } repo.checks["hc-test-1"] = check retrieved, err := svc.Get(context.Background(), "hc-test-1") if err != nil { t.Fatalf("Get failed: %v", err) } if retrieved.Endpoint != "example.com:443" { t.Errorf("Expected endpoint example.com:443, got %s", retrieved.Endpoint) } } func TestHealthCheckService_Get_NotFound(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) _, err := svc.Get(context.Background(), "nonexistent") if err == nil { t.Fatal("Expected error for nonexistent check") } } func TestHealthCheckService_List_Success(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) check1 := &domain.EndpointHealthCheck{ ID: "hc-1", Endpoint: "api.example.com:443", Status: domain.HealthStatusHealthy, } check2 := &domain.EndpointHealthCheck{ ID: "hc-2", Endpoint: "web.example.com:443", Status: domain.HealthStatusDegraded, } repo.checks["hc-1"] = check1 repo.checks["hc-2"] = check2 checks, total, err := svc.List(context.Background(), nil) if err != nil { t.Fatalf("List failed: %v", err) } if len(checks) != 2 { t.Errorf("Expected 2 checks, got %d", len(checks)) } if total != 2 { t.Errorf("Expected total 2, got %d", total) } } func TestHealthCheckService_Delete_Success(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) check := &domain.EndpointHealthCheck{ ID: "hc-test-1", Endpoint: "example.com:443", } repo.checks["hc-test-1"] = check err := svc.Delete(context.Background(), "hc-test-1") if err != nil { t.Fatalf("Delete failed: %v", err) } if _, ok := repo.checks["hc-test-1"]; ok { t.Fatal("Expected check to be deleted") } } func TestHealthCheckService_AcknowledgeIncident_Success(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) check := &domain.EndpointHealthCheck{ ID: "hc-test-1", Endpoint: "example.com:443", Status: domain.HealthStatusDown, Acknowledged: false, } repo.checks["hc-test-1"] = check err := svc.AcknowledgeIncident(context.Background(), "hc-test-1", "user@example.com") if err != nil { t.Fatalf("AcknowledgeIncident failed: %v", err) } retrieved := repo.checks["hc-test-1"] if !retrieved.Acknowledged { t.Fatal("Expected Acknowledged to be true") } if retrieved.AcknowledgedBy != "user@example.com" { t.Errorf("Expected AcknowledgedBy to be user@example.com, got %s", retrieved.AcknowledgedBy) } if retrieved.AcknowledgedAt == nil { t.Fatal("Expected AcknowledgedAt to be set") } } func TestHealthCheckService_GetSummary_Success(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) repo.getSummaryResult = &domain.HealthCheckSummary{ Healthy: 5, Degraded: 2, Down: 1, CertMismatch: 1, Unknown: 0, } summary, err := svc.GetSummary(context.Background()) if err != nil { t.Fatalf("GetSummary failed: %v", err) } if summary.Healthy != 5 { t.Errorf("Expected 5 healthy, got %d", summary.Healthy) } } func TestHealthCheckService_RunHealthChecks_NoEndpoints(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) err := svc.RunHealthChecks(context.Background()) if err != nil { t.Fatalf("RunHealthChecks failed: %v", err) } } func TestHealthCheckService_PurgeOldHistory_Success(t *testing.T) { repo := newMockHealthCheckRepo() logger := newTestLogger() svc := NewHealthCheckService(repo, nil, logger, 10, 5*time.Second, 30*24*time.Hour, false) err := svc.PurgeOldHistory(context.Background()) if err != nil { t.Fatalf("PurgeOldHistory failed: %v", err) } }