diff --git a/internal/notifications/email_enhanced_test.go b/internal/notifications/email_enhanced_test.go new file mode 100644 index 0000000..3569dc8 --- /dev/null +++ b/internal/notifications/email_enhanced_test.go @@ -0,0 +1,463 @@ +package notifications + +import ( + "strings" + "testing" + "time" +) + +func TestNewEnhancedEmailManager(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "smtp.example.com", + SMTPPort: 587, + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + RateLimit: 10, + } + + manager := NewEnhancedEmailManager(config) + if manager == nil { + t.Fatal("NewEnhancedEmailManager returned nil") + } + if manager.rateLimit == nil { + t.Fatal("rate limiter not initialized") + } + if manager.rateLimit.rate != 10 { + t.Errorf("expected rate limit 10, got %d", manager.rateLimit.rate) + } +} + +func TestCheckRateLimit_NoLimit(t *testing.T) { + manager := NewEnhancedEmailManager(EmailProviderConfig{ + RateLimit: 0, // No limit + }) + + // Should always succeed when no rate limit + for i := 0; i < 100; i++ { + if err := manager.checkRateLimit(); err != nil { + t.Errorf("checkRateLimit should not error with no limit: %v", err) + } + } +} + +func TestCheckRateLimit_ExceedsLimit(t *testing.T) { + manager := NewEnhancedEmailManager(EmailProviderConfig{ + RateLimit: 3, + }) + + // First 3 should succeed + for i := 0; i < 3; i++ { + if err := manager.checkRateLimit(); err != nil { + t.Errorf("call %d should succeed: %v", i+1, err) + } + } + + // 4th should fail + err := manager.checkRateLimit() + if err == nil { + t.Error("expected rate limit error on 4th call") + } + if !strings.Contains(err.Error(), "rate limit exceeded") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestCheckRateLimit_ResetsAfterMinute(t *testing.T) { + manager := NewEnhancedEmailManager(EmailProviderConfig{ + RateLimit: 2, + }) + + // Use up the limit + manager.checkRateLimit() + manager.checkRateLimit() + + // Manually set lastSent to over a minute ago + manager.rateLimit.mu.Lock() + manager.rateLimit.lastSent = time.Now().Add(-2 * time.Minute) + manager.rateLimit.mu.Unlock() + + // Should succeed after reset + if err := manager.checkRateLimit(); err != nil { + t.Errorf("should succeed after minute reset: %v", err) + } +} + +func TestSendViaProvider_ProviderUsernameDefaults(t *testing.T) { + tests := []struct { + name string + provider string + initialUsername string + expectedUsername string + }{ + { + name: "SendGrid sets apikey username", + provider: "SendGrid", + initialUsername: "", + expectedUsername: "apikey", + }, + { + name: "SendGrid preserves existing username", + provider: "SendGrid", + initialUsername: "custom", + expectedUsername: "custom", + }, + { + name: "SparkPost sets SMTP_Injection username", + provider: "SparkPost", + initialUsername: "", + expectedUsername: "SMTP_Injection", + }, + { + name: "Resend sets resend username", + provider: "Resend", + initialUsername: "", + expectedUsername: "resend", + }, + { + name: "Unknown provider leaves username unchanged", + provider: "Custom", + initialUsername: "", + expectedUsername: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", // Will fail to connect + SMTPPort: 587, + Username: tt.initialUsername, + Password: "test", + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + Provider: tt.provider, + AuthRequired: true, + } + + manager := NewEnhancedEmailManager(config) + // Call sendViaProvider - it will fail on connection, but will have set username + _ = manager.sendViaProvider([]byte("test")) + + if manager.config.Username != tt.expectedUsername { + t.Errorf("expected username %q, got %q", tt.expectedUsername, manager.config.Username) + } + }) + } +} + +func TestSendViaProvider_PostmarkUsernameFromPassword(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: 587, + Username: "", + Password: "postmark-api-token", + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + Provider: "Postmark", + AuthRequired: true, + } + + manager := NewEnhancedEmailManager(config) + _ = manager.sendViaProvider([]byte("test")) + + // Postmark copies password to username when username is empty + if manager.config.Username != "postmark-api-token" { + t.Errorf("expected username to be set from password, got %q", manager.config.Username) + } +} + +func TestSendViaProvider_RoutingByTLSConfig(t *testing.T) { + tests := []struct { + name string + tls bool + startTLS bool + port int + expectsError string // Partial match of expected error + }{ + { + name: "TLS true routes to sendTLS", + tls: true, + startTLS: false, + port: 587, + expectsError: "TLS dial failed", + }, + { + name: "Port 465 routes to sendTLS", + tls: false, + startTLS: false, + port: 465, + expectsError: "TLS dial failed", + }, + { + name: "StartTLS routes to sendStartTLS", + tls: false, + startTLS: true, + port: 587, + expectsError: "TCP dial failed", + }, + { + name: "Plain routes to sendPlain", + tls: false, + startTLS: false, + port: 25, + expectsError: "TCP dial failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: tt.port, + TLS: tt.tls, + StartTLS: tt.startTLS, + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + } + + manager := NewEnhancedEmailManager(config) + err := manager.sendViaProvider([]byte("test")) + + if err == nil { + t.Error("expected connection error") + return + } + + if !strings.Contains(err.Error(), tt.expectsError) { + t.Errorf("expected error containing %q, got %q", tt.expectsError, err.Error()) + } + }) + } +} + +func TestSendEmailWithRetry_RetriesOnFailure(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: 587, + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + MaxRetries: 2, + RetryDelay: 0, // No delay for tests + } + + manager := NewEnhancedEmailManager(config) + err := manager.SendEmailWithRetry("Test", "
test
", "test") + + if err == nil { + t.Error("expected error after all retries exhausted") + } + + // Should mention the retry count + if !strings.Contains(err.Error(), "3 attempts") { + t.Errorf("error should mention attempt count: %v", err) + } +} + +func TestSendEmailWithRetry_RateLimitPreventsRetry(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: 587, + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + MaxRetries: 3, + RetryDelay: 0, + RateLimit: 1, // Only 1 per minute + } + + manager := NewEnhancedEmailManager(config) + + // First send uses the 1 allowed + err := manager.SendEmailWithRetry("Test", "test
", "test") + if err == nil { + t.Error("expected error (connection should fail)") + } + + // Second send should hit rate limit on all retries + err = manager.SendEmailWithRetry("Test2", "test
", "test") + if err == nil { + t.Error("expected rate limit error") + } + if !strings.Contains(err.Error(), "rate limit exceeded") { + t.Errorf("expected rate limit error, got: %v", err) + } +} + +func TestSendEmailOnce_BuildsMultipartMessage(t *testing.T) { + // We can't test actual sending, but we can verify the method doesn't panic + // with valid inputs and returns expected connection error + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: 587, + From: "sender@example.com", + To: []string{"recipient@example.com"}, + }, + ReplyTo: "reply@example.com", + } + + manager := NewEnhancedEmailManager(config) + err := manager.sendEmailOnce("Test Subject", "HTML Body
", "Text Body") + + // Should fail on connection, not on message building + if err == nil { + t.Error("expected connection error") + } + // Error should be from connection, not from message construction + if strings.Contains(err.Error(), "message") && strings.Contains(err.Error(), "build") { + t.Errorf("unexpected message build error: %v", err) + } +} + +func TestTestConnection_TLSRouting(t *testing.T) { + tests := []struct { + name string + tls bool + port int + wantTLS bool + }{ + {"TLS true uses TLS dial", true, 587, true}, + {"Port 465 uses TLS dial", false, 465, true}, + {"Port 587 without TLS uses plain dial", false, 587, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: tt.port, + TLS: tt.tls, + }, + } + + manager := NewEnhancedEmailManager(config) + err := manager.TestConnection() + + if err == nil { + t.Error("expected connection error") + } + + // Verify error message indicates correct connection type + if tt.wantTLS && strings.Contains(err.Error(), "TCP dial") { + t.Error("TLS connection should not produce TCP dial error") + } + }) + } +} + +func TestSendTLS_ConnectionError(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: 465, + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + } + + manager := NewEnhancedEmailManager(config) + err := manager.sendTLS("invalid.host.test:465", nil, []byte("test")) + + if err == nil { + t.Error("expected TLS dial error") + } + if !strings.Contains(err.Error(), "TLS dial failed") { + t.Errorf("expected TLS dial error, got: %v", err) + } +} + +func TestSendStartTLS_ConnectionError(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: 587, + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + } + + manager := NewEnhancedEmailManager(config) + err := manager.sendStartTLS("invalid.host.test:587", nil, []byte("test")) + + if err == nil { + t.Error("expected TCP dial error") + } + if !strings.Contains(err.Error(), "TCP dial failed") { + t.Errorf("expected TCP dial error, got: %v", err) + } +} + +func TestSendPlain_ConnectionError(t *testing.T) { + config := EmailProviderConfig{ + EmailConfig: EmailConfig{ + SMTPHost: "invalid.host.test", + SMTPPort: 25, + From: "test@example.com", + To: []string{"recipient@example.com"}, + }, + } + + manager := NewEnhancedEmailManager(config) + err := manager.sendPlain("invalid.host.test:25", nil, []byte("test")) + + if err == nil { + t.Error("expected TCP dial error") + } + if !strings.Contains(err.Error(), "TCP dial failed") { + t.Errorf("expected TCP dial error, got: %v", err) + } +} + +func TestCheckRateLimit_NegativeLimit(t *testing.T) { + // Negative rate limit should be treated as no limit + manager := NewEnhancedEmailManager(EmailProviderConfig{ + RateLimit: -1, + }) + + for i := 0; i < 10; i++ { + if err := manager.checkRateLimit(); err != nil { + t.Errorf("negative rate limit should allow all calls: %v", err) + } + } +} + +func TestCheckRateLimit_Concurrency(t *testing.T) { + manager := NewEnhancedEmailManager(EmailProviderConfig{ + RateLimit: 100, + }) + + // Run concurrent rate limit checks + done := make(chan bool, 50) + for i := 0; i < 50; i++ { + go func() { + manager.checkRateLimit() + done <- true + }() + } + + // Wait for all goroutines + for i := 0; i < 50; i++ { + <-done + } + + // Verify counter is correct (should be 50) + manager.rateLimit.mu.Lock() + count := manager.rateLimit.sentCount + manager.rateLimit.mu.Unlock() + + if count != 50 { + t.Errorf("expected count 50 after concurrent calls, got %d", count) + } +}