mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 04:06:21 +00:00
150 lines
5.8 KiB
Go
150 lines
5.8 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
|
"github.com/stretchr/testify/mock"
|
|
)
|
|
|
|
func TestGetNotificationHealthReportsRetainedTerminalFailures(t *testing.T) {
|
|
mockMonitor := new(MockNotificationMonitor)
|
|
mockManager := new(MockNotificationManager)
|
|
mockPersistence := new(MockNotificationConfigPersistence)
|
|
mockMonitor.On("GetNotificationManager").Return(mockManager)
|
|
mockMonitor.On("GetConfigPersistence").Return(mockPersistence)
|
|
mockManager.On("GetQueueStats").Return(map[string]int{
|
|
"pending": 4,
|
|
"sending": 1,
|
|
"sent": 9,
|
|
"failed": 2,
|
|
"dlq": 3,
|
|
}, nil).Once()
|
|
mockManager.On("GetTelemetryStats", mock.Anything).Return(notifications.TelemetryStats{
|
|
Failures: 5,
|
|
FailureClasses: notifications.NotificationFailureClassCounts{
|
|
Authentication: 3,
|
|
Connectivity: 2,
|
|
},
|
|
}, nil).Once()
|
|
mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once()
|
|
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
|
|
mockPersistence.On("IsEncryptionEnabled").Return(true).Once()
|
|
|
|
rec := httptest.NewRecorder()
|
|
NewNotificationHandlers(nil, mockMonitor).GetNotificationHealth(
|
|
rec,
|
|
httptest.NewRequest(http.MethodGet, "/api/notifications/health", nil),
|
|
)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
var response struct {
|
|
OverallHealthy bool `json:"overall_healthy"`
|
|
Queue struct {
|
|
Healthy bool `json:"healthy"`
|
|
Status string `json:"status"`
|
|
AttentionRequired int `json:"attention_required"`
|
|
ReasonCodes []string `json:"reason_codes"`
|
|
CompletedRetentionDays int `json:"completed_retention_days"`
|
|
DeadLetterRetentionDays int `json:"dead_letter_retention_days"`
|
|
CountsAreRetentionBounded bool `json:"counts_are_retention_bounded"`
|
|
RetryAttemptsAffectHealth bool `json:"retry_attempts_affect_health"`
|
|
TerminalFailuresAffectHealth bool `json:"terminal_failures_affect_health"`
|
|
FailureClasses7d map[string]int `json:"failure_classes_7d"`
|
|
FailureClassesAvailable bool `json:"failure_classes_available"`
|
|
FailureClassWindowDays int `json:"failure_class_window_days"`
|
|
} `json:"queue"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode health response: %v", err)
|
|
}
|
|
if response.OverallHealthy || response.Queue.Healthy || response.Queue.Status != "degraded" {
|
|
t.Fatalf("health response = %#v, want degraded and unhealthy", response)
|
|
}
|
|
if response.Queue.AttentionRequired != 5 {
|
|
t.Fatalf("attention_required = %d, want 5", response.Queue.AttentionRequired)
|
|
}
|
|
if len(response.Queue.ReasonCodes) != 2 ||
|
|
response.Queue.ReasonCodes[0] != "retained_failed_deliveries" ||
|
|
response.Queue.ReasonCodes[1] != "retained_dead_letter_deliveries" {
|
|
t.Fatalf("reason_codes = %#v", response.Queue.ReasonCodes)
|
|
}
|
|
if response.Queue.CompletedRetentionDays != 7 ||
|
|
response.Queue.DeadLetterRetentionDays != 30 ||
|
|
!response.Queue.CountsAreRetentionBounded ||
|
|
response.Queue.RetryAttemptsAffectHealth ||
|
|
!response.Queue.TerminalFailuresAffectHealth {
|
|
t.Fatalf("queue semantics = %#v", response.Queue)
|
|
}
|
|
if !response.Queue.FailureClassesAvailable ||
|
|
response.Queue.FailureClassWindowDays != 7 ||
|
|
response.Queue.FailureClasses7d["authentication"] != 3 ||
|
|
response.Queue.FailureClasses7d["connectivity"] != 2 {
|
|
t.Fatalf("failure classes = %#v", response.Queue)
|
|
}
|
|
}
|
|
|
|
func TestGetNotificationHealthFailsClosedWhenQueueStatsAreUnavailable(t *testing.T) {
|
|
mockMonitor := new(MockNotificationMonitor)
|
|
mockManager := new(MockNotificationManager)
|
|
mockPersistence := new(MockNotificationConfigPersistence)
|
|
mockMonitor.On("GetNotificationManager").Return(mockManager)
|
|
mockMonitor.On("GetConfigPersistence").Return(mockPersistence)
|
|
mockManager.On("GetQueueStats").Return(nil, errors.New("database path /secret unavailable")).Once()
|
|
mockManager.On("GetTelemetryStats", mock.Anything).Return(
|
|
notifications.TelemetryStats{},
|
|
errors.New("database unavailable"),
|
|
).Once()
|
|
mockManager.On("GetEmailConfig").Return(notifications.EmailConfig{}).Once()
|
|
mockManager.On("GetWebhooks").Return([]notifications.WebhookConfig{}).Once()
|
|
mockPersistence.On("IsEncryptionEnabled").Return(false).Once()
|
|
|
|
rec := httptest.NewRecorder()
|
|
NewNotificationHandlers(nil, mockMonitor).GetNotificationHealth(
|
|
rec,
|
|
httptest.NewRequest(http.MethodGet, "/api/notifications/health", nil),
|
|
)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
var response struct {
|
|
OverallHealthy bool `json:"overall_healthy"`
|
|
Queue struct {
|
|
Healthy bool `json:"healthy"`
|
|
Status string `json:"status"`
|
|
ReasonCodes []string `json:"reason_codes"`
|
|
} `json:"queue"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode health response: %v", err)
|
|
}
|
|
if response.OverallHealthy || response.Queue.Healthy || response.Queue.Status != "unavailable" {
|
|
t.Fatalf("health response = %#v, want unavailable and unhealthy", response)
|
|
}
|
|
if len(response.Queue.ReasonCodes) != 1 ||
|
|
response.Queue.ReasonCodes[0] != "queue_stats_unavailable" {
|
|
t.Fatalf("reason_codes = %#v", response.Queue.ReasonCodes)
|
|
}
|
|
if body := rec.Body.String(); body == "" ||
|
|
containsAny(body, "database path", "/secret") {
|
|
t.Fatalf("health response exposed internal queue error: %s", body)
|
|
}
|
|
}
|
|
|
|
func containsAny(value string, needles ...string) bool {
|
|
for _, needle := range needles {
|
|
if needle != "" && strings.Contains(value, needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|