diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e990cdc..d7cab59 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,8 @@ # GitHub Actions Test Workflow # Runs tests with coverage on PRs and pushes to main +# - For PRs: Tests only affected packages (changed files + dependents) +# - For main: Runs full test suite +# - Enforces minimum coverage threshold name: Test @@ -16,6 +19,7 @@ permissions: env: GO_VERSION: '1.24' + COVERAGE_THRESHOLD: 60 # Minimum coverage percentage required jobs: test: @@ -24,6 +28,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required to get full history for diff - name: Set up Go uses: actions/setup-go@v5 @@ -34,7 +40,92 @@ jobs: - name: Download dependencies run: go mod download - - name: Run tests with coverage + - name: Get changed packages + id: changed + if: github.event_name == 'pull_request' + run: | + # Get list of changed Go files + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.go' | grep -v '_test.go$' | grep -v '/test/' || true) + + if [ -z "$CHANGED_FILES" ]; then + echo "No Go source files changed" + echo "packages=" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Changed files:" + echo "$CHANGED_FILES" + + # Get packages for changed files + CHANGED_PKGS="" + for file in $CHANGED_FILES; do + if [ -f "$file" ]; then + pkg=$(dirname "$file") + CHANGED_PKGS="$CHANGED_PKGS ./$pkg" + fi + done + + # Deduplicate and find dependent packages + if [ -n "$CHANGED_PKGS" ]; then + UNIQUE_PKGS=$(echo "$CHANGED_PKGS" | tr ' ' '\n' | sort -u | tr '\n' ' ') + echo "Changed packages: $UNIQUE_PKGS" + + # Find all packages that depend on changed packages + ALL_PKGS=$(go list ./... | grep -v '/test/e2e') + AFFECTED_PKGS="$UNIQUE_PKGS" + + for pkg in $UNIQUE_PKGS; do + # Find packages that import this package + PKG_PATH=$(go list -f '{{.ImportPath}}' $pkg 2>/dev/null || true) + if [ -n "$PKG_PATH" ]; then + DEPENDENTS=$(echo "$ALL_PKGS" | xargs -I {} sh -c "go list -f '{{range .Imports}}{{.}} {{end}}' {} 2>/dev/null | grep -q '$PKG_PATH' && echo {}" || true) + AFFECTED_PKGS="$AFFECTED_PKGS $DEPENDENTS" + fi + done + + # Deduplicate final list + FINAL_PKGS=$(echo "$AFFECTED_PKGS" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ') + echo "Affected packages (including dependents): $FINAL_PKGS" + echo "packages=$FINAL_PKGS" >> $GITHUB_OUTPUT + else + echo "packages=" >> $GITHUB_OUTPUT + fi + + - name: Run tests (PR - affected packages only) + if: github.event_name == 'pull_request' && steps.changed.outputs.packages != '' + run: | + echo "Testing affected packages: ${{ steps.changed.outputs.packages }}" + go test -v -race -coverprofile=coverage.out -covermode=atomic ${{ steps.changed.outputs.packages }} + + - name: Run tests (full suite) + if: github.event_name == 'push' || (github.event_name == 'pull_request' && steps.changed.outputs.packages == '') + run: | + echo "Running full test suite" + go test -v -race -coverprofile=coverage.out -covermode=atomic $(go list ./... | grep -v '/test/e2e') + + - name: Check coverage threshold + if: always() && hashFiles('coverage.out') != '' run: | - go test -v -race -coverprofile=coverage.out -covermode=atomic $(go list ./... | grep -v /test/e2e) - go tool cover -func=coverage.out | tail -1 + # Display coverage summary + echo "=== Coverage Summary ===" + go tool cover -func=coverage.out + + # Extract total coverage percentage + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "" + echo "Total coverage: ${COVERAGE}%" + echo "Minimum threshold: ${COVERAGE_THRESHOLD}%" + + # Check if coverage meets threshold + if [ -z "$COVERAGE" ]; then + echo "::warning::Could not determine coverage percentage" + exit 0 + fi + + # Compare coverage against threshold (using bc for float comparison) + if echo "$COVERAGE < $COVERAGE_THRESHOLD" | bc -l | grep -q 1; then + echo "::error::Coverage ${COVERAGE}% is below the minimum threshold of ${COVERAGE_THRESHOLD}%" + exit 1 + fi + + echo "::notice::Coverage ${COVERAGE}% meets the minimum threshold of ${COVERAGE_THRESHOLD}%" diff --git a/internal/adapter/dto/alertmanager_test.go b/internal/adapter/dto/alertmanager_test.go new file mode 100644 index 0000000..df124c9 --- /dev/null +++ b/internal/adapter/dto/alertmanager_test.go @@ -0,0 +1,119 @@ +package dto + +import ( + "testing" + "time" + + "github.com/altuslabsxyz/alert-bridge/internal/domain/entity" +) + +func TestMapSeverity(t *testing.T) { + tests := []struct { + name string + input string + expected entity.AlertSeverity + }{ + {"critical", "critical", entity.SeverityCritical}, + {"page", "page", entity.SeverityCritical}, + {"warning", "warning", entity.SeverityWarning}, + {"warn", "warn", entity.SeverityWarning}, + {"info", "info", entity.SeverityInfo}, + {"empty", "", entity.SeverityInfo}, + {"unknown", "unknown", entity.SeverityInfo}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapSeverity(tt.input) + if got != tt.expected { + t.Errorf("mapSeverity(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestToProcessAlertInput(t *testing.T) { + now := time.Now() + + alert := AlertmanagerAlert{ + Status: "firing", + Fingerprint: "abc123", + Labels: map[string]string{ + "alertname": "HighCPU", + "instance": "server1:9090", + "job": "prometheus", + "severity": "critical", + }, + Annotations: map[string]string{ + "summary": "CPU usage is high", + "description": "CPU usage exceeded 90%", + }, + StartsAt: now, + } + + input := ToProcessAlertInput(alert) + + if input.Fingerprint != "abc123" { + t.Errorf("Fingerprint = %v, want %v", input.Fingerprint, "abc123") + } + if input.Name != "HighCPU" { + t.Errorf("Name = %v, want %v", input.Name, "HighCPU") + } + if input.Instance != "server1:9090" { + t.Errorf("Instance = %v, want %v", input.Instance, "server1:9090") + } + if input.Target != "prometheus" { + t.Errorf("Target = %v, want %v", input.Target, "prometheus") + } + if input.Summary != "CPU usage is high" { + t.Errorf("Summary = %v, want %v", input.Summary, "CPU usage is high") + } + if input.Severity != entity.SeverityCritical { + t.Errorf("Severity = %v, want %v", input.Severity, entity.SeverityCritical) + } + if input.Status != "firing" { + t.Errorf("Status = %v, want %v", input.Status, "firing") + } +} + +func TestAlertmanagerAlert_IsFiring(t *testing.T) { + tests := []struct { + name string + status string + want bool + }{ + {"firing", "firing", true}, + {"resolved", "resolved", false}, + {"other", "pending", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := AlertmanagerAlert{Status: tt.status} + if got := a.IsFiring(); got != tt.want { + t.Errorf("IsFiring() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAlertmanagerAlert_IsResolved(t *testing.T) { + tests := []struct { + name string + status string + want bool + }{ + {"resolved", "resolved", true}, + {"firing", "firing", false}, + {"other", "pending", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := AlertmanagerAlert{Status: tt.status} + if got := a.IsResolved(); got != tt.want { + t.Errorf("IsResolved() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/adapter/dto/pagerduty_test.go b/internal/adapter/dto/pagerduty_test.go new file mode 100644 index 0000000..be12f86 --- /dev/null +++ b/internal/adapter/dto/pagerduty_test.go @@ -0,0 +1,27 @@ +package dto + +import "testing" + +func TestIsSupportedEventType(t *testing.T) { + tests := []struct { + name string + eventType string + want bool + }{ + {"acknowledged", "incident.acknowledged", true}, + {"resolved", "incident.resolved", true}, + {"unacknowledged", "incident.unacknowledged", true}, + {"reassigned", "incident.reassigned", true}, + {"triggered", "incident.triggered", false}, + {"empty", "", false}, + {"invalid", "invalid.event", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsSupportedEventType(tt.eventType); got != tt.want { + t.Errorf("IsSupportedEventType(%q) = %v, want %v", tt.eventType, got, tt.want) + } + }) + } +} diff --git a/internal/adapter/dto/slack_command_dto_test.go b/internal/adapter/dto/slack_command_dto_test.go new file mode 100644 index 0000000..85d9ca3 --- /dev/null +++ b/internal/adapter/dto/slack_command_dto_test.go @@ -0,0 +1,144 @@ +package dto + +import ( + "testing" + "time" +) + +func TestParseDuration(t *testing.T) { + tests := []struct { + name string + input string + want time.Duration + }{ + {"minutes", "30m", 30 * time.Minute}, + {"hours", "2h", 2 * time.Hour}, + {"days", "7d", 7 * 24 * time.Hour}, + {"weeks", "1w", 7 * 24 * time.Hour}, + {"invalid", "abc", 0}, + {"empty", "", 0}, + {"no_unit", "123", 0}, + {"negative", "-1h", 0}, + {"zero", "0h", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseDuration(tt.input) + if got != tt.want { + t.Errorf("parseDuration(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestSlackCommandDTO_PeriodFilter(t *testing.T) { + tests := []struct { + name string + text string + want time.Duration + }{ + {"empty", "", 0}, + {"today", "today", 24 * time.Hour}, + {"week", "week", 7 * 24 * time.Hour}, + {"thisweek", "thisweek", 7 * 24 * time.Hour}, + {"all", "all", 0}, + {"1h", "1h", time.Hour}, + {"24h", "24h", 24 * time.Hour}, + {"7d", "7d", 7 * 24 * time.Hour}, + {"invalid", "invalid", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dto := &SlackCommandDTO{Text: tt.text} + got := dto.PeriodFilter() + if got != tt.want { + t.Errorf("PeriodFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSlackCommandDTO_PeriodDescription(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + {"empty", "", "all time"}, + {"all", "all", "all time"}, + {"1h", "1h", "last 1 hour(s)"}, + {"24h", "24h", "last 1 day(s)"}, + {"7d", "7d", "last 1 week(s)"}, + {"3d", "3d", "last 3 day(s)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dto := &SlackCommandDTO{Text: tt.text} + got := dto.PeriodDescription() + if got != tt.want { + t.Errorf("PeriodDescription() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSlackCommandDTO_SeverityFilter(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + {"empty", "", ""}, + {"critical", "critical", "critical"}, + {"warning", "warning", "warning"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dto := &SlackCommandDTO{Text: tt.text} + got := dto.SeverityFilter() + if got != tt.want { + t.Errorf("SeverityFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSlackCommandDTO_ParseSilenceRequest(t *testing.T) { + tests := []struct { + name string + text string + wantAction SilenceAction + }{ + {"empty defaults to list", "", SilenceActionList}, + {"list", "list", SilenceActionList}, + {"create", "create", SilenceActionOpenModal}, + {"delete", "delete abc123", SilenceActionDelete}, + {"duration opens modal", "1h", SilenceActionOpenModal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dto := &SlackCommandDTO{ + Text: tt.text, + UserID: "U123", + UserName: "testuser", + } + got := dto.ParseSilenceRequest() + if got.Action != tt.wantAction { + t.Errorf("ParseSilenceRequest().Action = %v, want %v", got.Action, tt.wantAction) + } + }) + } + + t.Run("delete with ID", func(t *testing.T) { + dto := &SlackCommandDTO{Text: "delete silence-123"} + req := dto.ParseSilenceRequest() + if req.SilenceID != "silence-123" { + t.Errorf("SilenceID = %v, want %v", req.SilenceID, "silence-123") + } + }) +} diff --git a/internal/adapter/presenter/slack_alert_formatter_test.go b/internal/adapter/presenter/slack_alert_formatter_test.go new file mode 100644 index 0000000..89d313b --- /dev/null +++ b/internal/adapter/presenter/slack_alert_formatter_test.go @@ -0,0 +1,197 @@ +package presenter + +import ( + "testing" + "time" + + "github.com/altuslabsxyz/alert-bridge/internal/domain/entity" +) + +func TestSlackAlertFormatter_getSeverityMarker(t *testing.T) { + f := NewSlackAlertFormatter() + + tests := []struct { + name string + severity entity.AlertSeverity + want string + }{ + {"critical", entity.SeverityCritical, "🔴"}, + {"warning", entity.SeverityWarning, "🟡"}, + {"info", entity.SeverityInfo, "🔵"}, + {"unknown", entity.AlertSeverity("unknown"), "⚪"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.getSeverityMarker(tt.severity) + if got != tt.want { + t.Errorf("getSeverityMarker(%v) = %v, want %v", tt.severity, got, tt.want) + } + }) + } +} + +func TestSlackAlertFormatter_formatSeverity(t *testing.T) { + f := NewSlackAlertFormatter() + + tests := []struct { + name string + severity string + want string + }{ + {"critical", "critical", "Critical"}, + {"warning", "warning", "Warning"}, + {"info", "info", "Info"}, + {"unknown", "other", "other"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.formatSeverity(tt.severity) + if got != tt.want { + t.Errorf("formatSeverity(%v) = %v, want %v", tt.severity, got, tt.want) + } + }) + } +} + +func TestSlackAlertFormatter_formatDuration(t *testing.T) { + f := NewSlackAlertFormatter() + + tests := []struct { + name string + duration time.Duration + want string + }{ + {"seconds", 45 * time.Second, "45s"}, + {"minutes", 5 * time.Minute, "5m"}, + {"hours_minutes", 2*time.Hour + 30*time.Minute, "2h 30m"}, + {"days_hours", 3*24*time.Hour + 5*time.Hour, "3d 5h"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.formatDuration(tt.duration) + if got != tt.want { + t.Errorf("formatDuration(%v) = %v, want %v", tt.duration, got, tt.want) + } + }) + } +} + +func TestSlackAlertFormatter_joinDetails(t *testing.T) { + f := NewSlackAlertFormatter() + + tests := []struct { + name string + details []string + want string + }{ + {"empty", []string{}, ""}, + {"single", []string{"a"}, "a"}, + {"multiple", []string{"a", "b", "c"}, "a | b | c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.joinDetails(tt.details) + if got != tt.want { + t.Errorf("joinDetails(%v) = %v, want %v", tt.details, got, tt.want) + } + }) + } +} + +func TestSlackAlertFormatter_formatTopInstances(t *testing.T) { + f := NewSlackAlertFormatter() + + tests := []struct { + name string + instances map[string]int + limit int + wantLen int + }{ + { + name: "empty", + instances: map[string]int{}, + limit: 5, + wantLen: 0, + }, + { + name: "less than limit", + instances: map[string]int{"host1": 5, "host2": 3}, + limit: 5, + wantLen: 2, + }, + { + name: "more than limit", + instances: map[string]int{"host1": 5, "host2": 3, "host3": 10, "host4": 1, "host5": 2, "host6": 7}, + limit: 3, + wantLen: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.formatTopInstances(tt.instances, tt.limit) + if tt.wantLen == 0 && got != "" { + t.Errorf("formatTopInstances() should be empty, got %v", got) + } + if tt.wantLen > 0 && got == "" { + t.Errorf("formatTopInstances() should not be empty") + } + }) + } +} + +func TestSlackAlertFormatter_FormatAlertStatus(t *testing.T) { + f := NewSlackAlertFormatter() + + t.Run("empty alerts", func(t *testing.T) { + blocks := f.FormatAlertStatus([]*entity.Alert{}, "") + if len(blocks) == 0 { + t.Error("FormatAlertStatus should return blocks even for empty alerts") + } + }) + + t.Run("with severity filter", func(t *testing.T) { + blocks := f.FormatAlertStatus([]*entity.Alert{}, "critical") + if len(blocks) == 0 { + t.Error("FormatAlertStatus should return blocks with severity filter") + } + }) + + t.Run("with alerts", func(t *testing.T) { + alerts := []*entity.Alert{ + { + ID: "1", + Name: "TestAlert", + Severity: entity.SeverityCritical, + State: entity.StateActive, + FiredAt: time.Now().Add(-1 * time.Hour), + }, + } + blocks := f.FormatAlertStatus(alerts, "") + if len(blocks) < 3 { + t.Error("FormatAlertStatus should return multiple blocks for alerts") + } + }) + + t.Run("limits to 10 alerts", func(t *testing.T) { + alerts := make([]*entity.Alert, 15) + for i := range alerts { + alerts[i] = &entity.Alert{ + ID: string(rune('0' + i)), + Name: "TestAlert", + Severity: entity.SeverityInfo, + State: entity.StateActive, + FiredAt: time.Now(), + } + } + blocks := f.FormatAlertStatus(alerts, "") + // Should have header + summary + divider + 10 alerts with dividers + truncation message + footer + if len(blocks) == 0 { + t.Error("FormatAlertStatus should return blocks") + } + }) +}