rdev/internal/service/diagnostics_service_test.go
jordan 210064d490 feat: add diagnostics endpoint and external health monitoring
- Add /diagnostics endpoint for system health overview
- Add external health worker for monitoring Gitea, Woodpecker, Registry
- Add health check methods to Gitea and Woodpecker clients
- Remove hardcoded fallback projects (pantheon, aeries)
- Add diagnostics domain types and service layer
- Add comprehensive tests for diagnostics handler and service
- Fix tests to use registered test project instead of hardcoded one

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:10:56 -07:00

303 lines
7.9 KiB
Go

package service
import (
"context"
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
)
// mockOperationRepo implements port.OperationRepository for testing.
type mockOperationRepo struct {
operations []*domain.Operation
err error
}
func (m *mockOperationRepo) Create(_ context.Context, _ *domain.Operation) error {
return nil
}
func (m *mockOperationRepo) Update(_ context.Context, _ *domain.Operation) error {
return nil
}
func (m *mockOperationRepo) Get(_ context.Context, _ string) (*domain.Operation, error) {
return nil, domain.ErrOperationNotFound
}
func (m *mockOperationRepo) GetByCommitSHA(_ context.Context, _, _ string) (*domain.Operation, error) {
return nil, domain.ErrOperationNotFound
}
func (m *mockOperationRepo) List(_ context.Context, filter domain.OperationFilters) ([]*domain.Operation, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.Operation
for _, op := range m.operations {
if filter.ProjectID != "" && op.ProjectID != filter.ProjectID {
continue
}
result = append(result, op)
}
return result, nil
}
func (m *mockOperationRepo) AddStep(_ context.Context, _ string, _ domain.OperationStep) error {
return nil
}
func (m *mockOperationRepo) UpdateStep(_ context.Context, _ string, _ domain.OperationStep) error {
return nil
}
func (m *mockOperationRepo) Complete(_ context.Context, _ string, _ domain.OperationStatus, _ map[string]any, _, _ string) error {
return nil
}
func (m *mockOperationRepo) SetCommitSHA(_ context.Context, _, _ string) error {
return nil
}
func (m *mockOperationRepo) SetTriggeredBy(_ context.Context, _, _ string) error {
return nil
}
func (m *mockOperationRepo) DeleteOlderThan(_ context.Context, _ time.Time) (int64, error) {
return 0, nil
}
// mockRegistryChecker implements port.RegistryChecker for testing.
type mockRegistryChecker struct {
status domain.RegistryStatus
}
func (m *mockRegistryChecker) Check(_ context.Context) domain.RegistryStatus {
return m.status
}
// mockCIProvider implements port.CIProvider for testing.
type mockCIProvider struct {
pipelines []*domain.CIPipeline
steps *domain.CIPipelineSteps
err error
}
func (m *mockCIProvider) ActivateRepo(_ context.Context, _, _, _ string) (*domain.CIRepo, error) {
return nil, nil
}
func (m *mockCIProvider) DeactivateRepo(_ context.Context, _, _ string) error {
return nil
}
func (m *mockCIProvider) GetRepo(_ context.Context, _, _ string) (*domain.CIRepo, error) {
return nil, nil
}
func (m *mockCIProvider) ListRepos(_ context.Context) ([]*domain.CIRepo, error) {
return nil, nil
}
func (m *mockCIProvider) AddSecret(_ context.Context, _, _ string, _ domain.CISecret) error {
return nil
}
func (m *mockCIProvider) DeleteSecret(_ context.Context, _, _, _ string) error {
return nil
}
func (m *mockCIProvider) ListPipelines(_ context.Context, _, _ string) ([]*domain.CIPipeline, error) {
if m.err != nil {
return nil, m.err
}
return m.pipelines, nil
}
func (m *mockCIProvider) GetPipeline(_ context.Context, _, _ string, _ int64) (*domain.CIPipeline, error) {
return nil, nil
}
func (m *mockCIProvider) GetPipelineSteps(_ context.Context, _, _ string, _ int64) (*domain.CIPipelineSteps, error) {
if m.steps != nil {
return m.steps, nil
}
return nil, nil
}
func (m *mockCIProvider) TriggerBuild(_ context.Context, _, _, _ string) (int64, error) {
return 0, nil
}
func TestDiagnosticsService_GetDiagnostics_Healthy(t *testing.T) {
opRepo := &mockOperationRepo{
operations: []*domain.Operation{
{
ID: "op-1",
ProjectID: "test-project",
Type: domain.OperationTypeBuild,
Status: domain.OperationStatusCompleted,
StartedAt: time.Now().Add(-1 * time.Hour),
},
},
}
registry := &mockRegistryChecker{
status: domain.RegistryStatus{
Healthy: true,
URL: "https://registry.example.com",
Latency: "10ms",
LastChecked: time.Now(),
},
}
ci := &mockCIProvider{
pipelines: []*domain.CIPipeline{
{
Number: 42,
Status: "success",
Branch: "main",
Commit: "abc123",
},
},
}
svc := NewDiagnosticsService(opRepo, registry, ci, DiagnosticsServiceConfig{
DefaultGitOwner: "test-org",
})
diag, err := svc.GetDiagnostics(context.Background(), "test-project")
if err != nil {
t.Fatalf("GetDiagnostics() error = %v", err)
}
if diag.ProjectID != "test-project" {
t.Errorf("ProjectID = %q, want %q", diag.ProjectID, "test-project")
}
if diag.Summary != domain.DiagnosticsSummaryHealthy {
t.Errorf("Summary = %q, want %q", diag.Summary, domain.DiagnosticsSummaryHealthy)
}
if len(diag.Issues) != 0 {
t.Errorf("Issues count = %d, want 0", len(diag.Issues))
}
if len(diag.RecentOperations) != 1 {
t.Errorf("RecentOperations count = %d, want 1", len(diag.RecentOperations))
}
if diag.Registry == nil {
t.Error("Registry is nil, want non-nil")
}
if diag.CI == nil || !diag.CI.Available {
t.Error("CI not available")
}
}
func TestDiagnosticsService_GetDiagnostics_Unhealthy(t *testing.T) {
opRepo := &mockOperationRepo{
operations: []*domain.Operation{
{
ID: "op-1",
ProjectID: "test-project",
Type: domain.OperationTypeBuild,
Status: domain.OperationStatusFailed,
StartedAt: time.Now().Add(-1 * time.Hour),
Error: "deployment failed",
},
},
}
registry := &mockRegistryChecker{
status: domain.RegistryStatus{
Healthy: false,
URL: "https://registry.example.com",
Error: "connection refused",
LastChecked: time.Now(),
},
}
ci := &mockCIProvider{
pipelines: []*domain.CIPipeline{
{
Number: 43,
Status: "failure",
Branch: "main",
Commit: "def456",
Finished: time.Now().Add(-30 * time.Minute),
},
},
steps: &domain.CIPipelineSteps{
PipelineNumber: 43,
URL: "https://ci.example.com/build/43",
Steps: []domain.CIPipelineStep{
{
Name: "test",
Status: "failure",
Error: "tests failed",
Log: "FAIL: TestSomething",
},
},
},
}
svc := NewDiagnosticsService(opRepo, registry, ci, DiagnosticsServiceConfig{
DefaultGitOwner: "test-org",
})
diag, err := svc.GetDiagnostics(context.Background(), "test-project")
if err != nil {
t.Fatalf("GetDiagnostics() error = %v", err)
}
if diag.Summary != domain.DiagnosticsSummaryUnhealthy {
t.Errorf("Summary = %q, want %q", diag.Summary, domain.DiagnosticsSummaryUnhealthy)
}
// Should have 3 issues: failed operation, unhealthy registry, failed CI
if len(diag.Issues) < 3 {
t.Errorf("Issues count = %d, want at least 3", len(diag.Issues))
}
// Check CI failure details
if diag.CI == nil || diag.CI.LastFailure == nil {
t.Fatal("CI.LastFailure is nil")
}
if diag.CI.LastFailure.FailedStep != "test" {
t.Errorf("FailedStep = %q, want %q", diag.CI.LastFailure.FailedStep, "test")
}
}
func TestDiagnosticsService_GetDiagnostics_Degraded(t *testing.T) {
opRepo := &mockOperationRepo{
operations: []*domain.Operation{
{
ID: "op-1",
ProjectID: "test-project",
Type: domain.OperationTypeBuild,
Status: domain.OperationStatusCompleted,
StartedAt: time.Now().Add(-1 * time.Hour),
},
},
}
registry := &mockRegistryChecker{
status: domain.RegistryStatus{
Healthy: true,
URL: "https://registry.example.com",
LastChecked: time.Now(),
},
}
// No CI provider - should produce a warning but not error
svc := NewDiagnosticsService(opRepo, registry, nil, DiagnosticsServiceConfig{
DefaultGitOwner: "test-org",
})
diag, err := svc.GetDiagnostics(context.Background(), "test-project")
if err != nil {
t.Fatalf("GetDiagnostics() error = %v", err)
}
// Without CI, status should still be healthy (CI unavailable is not an issue)
if diag.Summary != domain.DiagnosticsSummaryHealthy {
t.Errorf("Summary = %q, want %q", diag.Summary, domain.DiagnosticsSummaryHealthy)
}
if diag.CI == nil || diag.CI.Available {
t.Error("CI should be not available when no provider")
}
}