package services import ( "regexp" "strings" "testing" "github.com/breakpilot/consent-service/internal/models" ) // ======================================== // Test All 19 Email Categories // ======================================== // TestEmailTemplateService_GetDefaultTemplateContent tests default content generation for each email type func TestEmailTemplateService_GetDefaultTemplateContent(t *testing.T) { service := &EmailTemplateService{} // All 19 email categories tests := []struct { name string emailType string language string wantSubject bool wantBodyHTML bool wantBodyText bool }{ // Auth Lifecycle (10 types) {"welcome_de", models.EmailTypeWelcome, "de", true, true, true}, {"email_verification_de", models.EmailTypeEmailVerification, "de", true, true, true}, {"password_reset_de", models.EmailTypePasswordReset, "de", true, true, true}, {"password_changed_de", models.EmailTypePasswordChanged, "de", true, true, true}, {"2fa_enabled_de", models.EmailType2FAEnabled, "de", true, true, true}, {"2fa_disabled_de", models.EmailType2FADisabled, "de", true, true, true}, {"new_device_login_de", models.EmailTypeNewDeviceLogin, "de", true, true, true}, {"suspicious_activity_de", models.EmailTypeSuspiciousActivity, "de", true, true, true}, {"account_locked_de", models.EmailTypeAccountLocked, "de", true, true, true}, {"account_unlocked_de", models.EmailTypeAccountUnlocked, "de", true, true, true}, // GDPR/Privacy (5 types) {"deletion_requested_de", models.EmailTypeDeletionRequested, "de", true, true, true}, {"deletion_confirmed_de", models.EmailTypeDeletionConfirmed, "de", true, true, true}, {"data_export_ready_de", models.EmailTypeDataExportReady, "de", true, true, true}, {"email_changed_de", models.EmailTypeEmailChanged, "de", true, true, true}, {"email_change_verify_de", models.EmailTypeEmailChangeVerify, "de", true, true, true}, // Consent Management (4 types) {"new_version_published_de", models.EmailTypeNewVersionPublished, "de", true, true, true}, {"consent_reminder_de", models.EmailTypeConsentReminder, "de", true, true, true}, {"consent_deadline_warning_de", models.EmailTypeConsentDeadlineWarning, "de", true, true, true}, {"account_suspended_de", models.EmailTypeAccountSuspended, "de", true, true, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(tt.emailType, tt.language) if tt.wantSubject && subject == "" { t.Errorf("GetDefaultTemplateContent(%s, %s): expected subject, got empty string", tt.emailType, tt.language) } if tt.wantBodyHTML && bodyHTML == "" { t.Errorf("GetDefaultTemplateContent(%s, %s): expected bodyHTML, got empty string", tt.emailType, tt.language) } if tt.wantBodyText && bodyText == "" { t.Errorf("GetDefaultTemplateContent(%s, %s): expected bodyText, got empty string", tt.emailType, tt.language) } }) } } // TestEmailTemplateService_GetDefaultTemplateContent_UnknownType tests default content for unknown type func TestEmailTemplateService_GetDefaultTemplateContent_UnknownType(t *testing.T) { service := &EmailTemplateService{} subject, bodyHTML, bodyText := service.GetDefaultTemplateContent("unknown_type", "de") // The service returns a fallback for unknown types if subject == "" { t.Errorf("GetDefaultTemplateContent(unknown_type, de): expected fallback subject, got empty") } if bodyHTML == "" { t.Errorf("GetDefaultTemplateContent(unknown_type, de): expected fallback bodyHTML, got empty") } if bodyText == "" { t.Errorf("GetDefaultTemplateContent(unknown_type, de): expected fallback bodyText, got empty") } } // TestEmailTemplateService_GetDefaultTemplateContent_UnsupportedLanguage tests fallback for unsupported language func TestEmailTemplateService_GetDefaultTemplateContent_UnsupportedLanguage(t *testing.T) { service := &EmailTemplateService{} // Test with unsupported language - should return fallback subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(models.EmailTypeWelcome, "fr") // Should return fallback (not empty, but generic) if subject == "" || bodyHTML == "" || bodyText == "" { t.Error("GetDefaultTemplateContent should return fallback for unsupported language") } } // TestReplaceVariables tests variable replacement in templates func TestReplaceVariables(t *testing.T) { tests := []struct { name string template string variables map[string]string expected string }{ { name: "single variable", template: "Hallo {{user_name}}!", variables: map[string]string{"user_name": "Max"}, expected: "Hallo Max!", }, { name: "multiple variables", template: "Hallo {{user_name}}, klicken Sie hier: {{reset_link}}", variables: map[string]string{"user_name": "Max", "reset_link": "https://example.com"}, expected: "Hallo Max, klicken Sie hier: https://example.com", }, { name: "no variables", template: "Hallo Welt!", variables: map[string]string{}, expected: "Hallo Welt!", }, { name: "missing variable - not replaced", template: "Hallo {{user_name}} und {{missing}}!", variables: map[string]string{"user_name": "Max"}, expected: "Hallo Max und {{missing}}!", }, { name: "empty template", template: "", variables: map[string]string{"user_name": "Max"}, expected: "", }, { name: "variable with special characters", template: "IP: {{ip_address}}", variables: map[string]string{"ip_address": "192.168.1.1"}, expected: "IP: 192.168.1.1", }, { name: "variable with URL", template: "Link: {{verification_url}}", variables: map[string]string{"verification_url": "https://example.com/verify?token=abc123&user=test"}, expected: "Link: https://example.com/verify?token=abc123&user=test", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := testReplaceVariables(tt.template, tt.variables) if result != tt.expected { t.Errorf("replaceVariables() = %s, want %s", result, tt.expected) } }) } } // testReplaceVariables is a test helper function for variable replacement func testReplaceVariables(template string, variables map[string]string) string { result := template for key, value := range variables { placeholder := "{{" + key + "}}" for i := 0; i < len(result); i++ { idx := testFindSubstring(result, placeholder) if idx == -1 { break } result = result[:idx] + value + result[idx+len(placeholder):] } } return result } func testFindSubstring(s, substr string) int { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return i } } return -1 } // TestEmailTypeConstantsExist verifies that all expected email types are defined func TestEmailTypeConstantsExist(t *testing.T) { // Test that all 19 email type constants are defined and produce non-empty templates types := []string{ // Auth Lifecycle models.EmailTypeWelcome, models.EmailTypeEmailVerification, models.EmailTypePasswordReset, models.EmailTypePasswordChanged, models.EmailType2FAEnabled, models.EmailType2FADisabled, models.EmailTypeNewDeviceLogin, models.EmailTypeSuspiciousActivity, models.EmailTypeAccountLocked, models.EmailTypeAccountUnlocked, // GDPR/Privacy models.EmailTypeDeletionRequested, models.EmailTypeDeletionConfirmed, models.EmailTypeDataExportReady, models.EmailTypeEmailChanged, models.EmailTypeEmailChangeVerify, // Consent Management models.EmailTypeNewVersionPublished, models.EmailTypeConsentReminder, models.EmailTypeConsentDeadlineWarning, models.EmailTypeAccountSuspended, } service := &EmailTemplateService{} for _, emailType := range types { t.Run(emailType, func(t *testing.T) { subject, bodyHTML, _ := service.GetDefaultTemplateContent(emailType, "de") if subject == "" { t.Errorf("Email type %s has no default subject", emailType) } if bodyHTML == "" { t.Errorf("Email type %s has no default body HTML", emailType) } }) } // Verify we have exactly 19 types if len(types) != 19 { t.Errorf("Expected 19 email types, got %d", len(types)) } } // TestEmailTemplateService_ValidateTemplateContent tests template content validation func TestEmailTemplateService_ValidateTemplateContent(t *testing.T) { tests := []struct { name string subject string bodyHTML string wantError bool }{ { name: "valid content", subject: "Test Subject", bodyHTML: "

Test Body

", wantError: false, }, { name: "empty subject", subject: "", bodyHTML: "

Test Body

", wantError: true, }, { name: "empty body", subject: "Test Subject", bodyHTML: "", wantError: true, }, { name: "both empty", subject: "", bodyHTML: "", wantError: true, }, { name: "whitespace only subject", subject: " ", bodyHTML: "

Test Body

", wantError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := testValidateTemplateContent(tt.subject, tt.bodyHTML) if (err != nil) != tt.wantError { t.Errorf("validateTemplateContent() error = %v, wantError %v", err, tt.wantError) } }) } } // testValidateTemplateContent is a test helper function to validate template content func testValidateTemplateContent(subject, bodyHTML string) error { if strings.TrimSpace(subject) == "" { return &templateValidationError{Field: "subject", Message: "subject is required"} } if strings.TrimSpace(bodyHTML) == "" { return &templateValidationError{Field: "body_html", Message: "body_html is required"} } return nil } // templateValidationError represents a validation error in email templates type templateValidationError struct { Field string Message string } func (e *templateValidationError) Error() string { return e.Field + ": " + e.Message } // TestGetTestVariablesForType tests that test variables are properly generated for each email type func TestGetTestVariablesForType(t *testing.T) { tests := []struct { emailType string expectedVars []string }{ // Auth Lifecycle {models.EmailTypeWelcome, []string{"user_name", "app_name"}}, {models.EmailTypeEmailVerification, []string{"user_name", "verification_url"}}, {models.EmailTypePasswordReset, []string{"reset_url"}}, {models.EmailTypePasswordChanged, []string{"user_name", "changed_at"}}, {models.EmailType2FAEnabled, []string{"user_name", "enabled_at"}}, {models.EmailType2FADisabled, []string{"user_name", "disabled_at"}}, {models.EmailTypeNewDeviceLogin, []string{"device", "location", "ip_address", "login_time"}}, {models.EmailTypeSuspiciousActivity, []string{"activity_type", "activity_time"}}, {models.EmailTypeAccountLocked, []string{"locked_at", "reason"}}, {models.EmailTypeAccountUnlocked, []string{"unlocked_at"}}, // GDPR/Privacy {models.EmailTypeDeletionRequested, []string{"deletion_date", "cancel_url"}}, {models.EmailTypeDeletionConfirmed, []string{"deleted_at"}}, {models.EmailTypeDataExportReady, []string{"download_url", "expires_in"}}, {models.EmailTypeEmailChanged, []string{"old_email", "new_email"}}, // Consent Management {models.EmailTypeNewVersionPublished, []string{"document_name", "version"}}, {models.EmailTypeConsentReminder, []string{"document_name", "days_left"}}, {models.EmailTypeConsentDeadlineWarning, []string{"document_name", "hours_left"}}, {models.EmailTypeAccountSuspended, []string{"suspended_at", "reason"}}, } for _, tt := range tests { t.Run(tt.emailType, func(t *testing.T) { vars := getTestVariablesForType(tt.emailType) for _, expected := range tt.expectedVars { if _, ok := vars[expected]; !ok { t.Errorf("getTestVariablesForType(%s) missing variable %s", tt.emailType, expected) } } }) } } // getTestVariablesForType returns test variables for a given email type func getTestVariablesForType(emailType string) map[string]string { // Common variables vars := map[string]string{ "user_name": "Max Mustermann", "user_email": "max@example.com", "app_name": "BreakPilot", "app_url": "https://breakpilot.app", "support_url": "https://breakpilot.app/support", "support_email": "support@breakpilot.app", "security_url": "https://breakpilot.app/security", "login_url": "https://breakpilot.app/login", } switch emailType { case models.EmailTypeEmailVerification: vars["verification_url"] = "https://breakpilot.app/verify?token=xyz789" vars["verification_code"] = "ABC123" vars["expires_in"] = "24 Stunden" case models.EmailTypePasswordReset: vars["reset_url"] = "https://breakpilot.app/reset?token=abc123" vars["reset_code"] = "RST456" vars["expires_in"] = "1 Stunde" vars["ip_address"] = "192.168.1.1" case models.EmailTypePasswordChanged: vars["changed_at"] = "14.12.2025 15:30 Uhr" vars["ip_address"] = "192.168.1.1" vars["device_info"] = "Chrome auf MacOS" case models.EmailType2FAEnabled: vars["enabled_at"] = "14.12.2025 15:30 Uhr" vars["device_info"] = "Chrome auf MacOS" case models.EmailType2FADisabled: vars["disabled_at"] = "14.12.2025 15:30 Uhr" vars["ip_address"] = "192.168.1.1" case models.EmailTypeNewDeviceLogin: vars["device"] = "Chrome auf MacOS" vars["device_info"] = "Chrome auf MacOS" vars["location"] = "Berlin, Deutschland" vars["ip_address"] = "192.168.1.1" vars["login_time"] = "14.12.2025 15:30 Uhr" case models.EmailTypeSuspiciousActivity: vars["activity_type"] = "Mehrere fehlgeschlagene Logins" vars["activity_time"] = "14.12.2025 15:30 Uhr" vars["ip_address"] = "192.168.1.1" case models.EmailTypeAccountLocked: vars["locked_at"] = "14.12.2025 15:30 Uhr" vars["reason"] = "Zu viele fehlgeschlagene Login-Versuche" vars["unlock_time"] = "14.12.2025 16:30 Uhr" case models.EmailTypeAccountUnlocked: vars["unlocked_at"] = "14.12.2025 16:30 Uhr" case models.EmailTypeDeletionRequested: vars["requested_at"] = "14.12.2025 15:30 Uhr" vars["deletion_date"] = "14.01.2026" vars["cancel_url"] = "https://breakpilot.app/cancel-deletion?token=del123" vars["data_info"] = "Profildaten, Consent-Historie, Audit-Logs" case models.EmailTypeDeletionConfirmed: vars["deleted_at"] = "14.01.2026 00:00 Uhr" vars["feedback_url"] = "https://breakpilot.app/feedback" case models.EmailTypeDataExportReady: vars["download_url"] = "https://breakpilot.app/download/export123" vars["expires_in"] = "7 Tage" vars["file_size"] = "2.5 MB" case models.EmailTypeEmailChanged: vars["old_email"] = "old@example.com" vars["new_email"] = "new@example.com" vars["changed_at"] = "14.12.2025 15:30 Uhr" case models.EmailTypeEmailChangeVerify: vars["new_email"] = "new@example.com" vars["verification_url"] = "https://breakpilot.app/verify-email?token=ver123" vars["expires_in"] = "24 Stunden" case models.EmailTypeNewVersionPublished: vars["document_name"] = "Datenschutzerklärung" vars["document_type"] = "privacy" vars["version"] = "2.0.0" vars["consent_url"] = "https://breakpilot.app/consent" vars["deadline"] = "31.12.2025" case models.EmailTypeConsentReminder: vars["document_name"] = "Nutzungsbedingungen" vars["days_left"] = "7" vars["consent_url"] = "https://breakpilot.app/consent" vars["deadline"] = "21.12.2025" case models.EmailTypeConsentDeadlineWarning: vars["document_name"] = "Nutzungsbedingungen" vars["hours_left"] = "24 Stunden" vars["consent_url"] = "https://breakpilot.app/consent" vars["consequences"] = "Ihr Konto wird temporär suspendiert." case models.EmailTypeAccountSuspended: vars["suspended_at"] = "14.12.2025 15:30 Uhr" vars["reason"] = "Fehlende Zustimmung zu Pflichtdokumenten" vars["documents"] = "- Nutzungsbedingungen v2.0\n- Datenschutzerklärung v3.0" vars["consent_url"] = "https://breakpilot.app/consent" } return vars } // TestEmailTemplateService_HTMLEscape tests that HTML is properly escaped in text version func TestEmailTemplateService_HTMLEscape(t *testing.T) { tests := []struct { name string html string expected string }{ { name: "simple paragraph", html: "

Hello World

", expected: "Hello World", }, { name: "link", html: `Click here`, expected: "Click here", }, { name: "bold text", html: "Important", expected: "Important", }, { name: "nested tags", html: "

Nested text

", expected: "Nested text", }, { name: "multiple tags", html: "

Title

Paragraph

", expected: "TitleParagraph", }, { name: "self-closing tag", html: "Line1
Line2", expected: "Line1Line2", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := stripHTMLTags(tt.html) if result != tt.expected { t.Errorf("stripHTMLTags() = %s, want %s", result, tt.expected) } }) } } // stripHTMLTags removes HTML tags from a string func stripHTMLTags(html string) string { result := "" inTag := false for _, r := range html { if r == '<' { inTag = true continue } if r == '>' { inTag = false continue } if !inTag { result += string(r) } } return result } // TestEmailTemplateService_AllTemplatesHaveVariables tests that all templates define their required variables func TestEmailTemplateService_AllTemplatesHaveVariables(t *testing.T) { service := &EmailTemplateService{} templateTypes := service.GetAllTemplateTypes() for _, tt := range templateTypes { t.Run(tt.TemplateType, func(t *testing.T) { // Get default template content subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(tt.TemplateType, "de") // Check that variables defined in template type are present in the content for _, varName := range tt.Variables { placeholder := "{{" + varName + "}}" foundInSubject := strings.Contains(subject, placeholder) foundInHTML := strings.Contains(bodyHTML, placeholder) foundInText := strings.Contains(bodyText, placeholder) // Variable should be present in at least one of subject, HTML or text if !foundInSubject && !foundInHTML && !foundInText { // Note: This is a warning, not an error, as some variables might be optional t.Logf("Warning: Variable %s defined for %s but not found in template content", varName, tt.TemplateType) } } // Check that all variables in content are defined re := regexp.MustCompile(`\{\{(\w+)\}\}`) allMatches := re.FindAllStringSubmatch(subject+bodyHTML+bodyText, -1) definedVars := make(map[string]bool) for _, v := range tt.Variables { definedVars[v] = true } for _, match := range allMatches { if len(match) > 1 { varName := match[1] if !definedVars[varName] { t.Logf("Warning: Variable {{%s}} found in template but not defined in variables list for %s", varName, tt.TemplateType) } } } }) } } // TestEmailTemplateService_TemplateVariableDescriptions tests that all variables have descriptions func TestEmailTemplateService_TemplateVariableDescriptions(t *testing.T) { service := &EmailTemplateService{} templateTypes := service.GetAllTemplateTypes() for _, tt := range templateTypes { t.Run(tt.TemplateType, func(t *testing.T) { for _, varName := range tt.Variables { if desc, ok := tt.Descriptions[varName]; !ok || desc == "" { t.Errorf("Variable %s in %s has no description", varName, tt.TemplateType) } } }) } } // TestEmailTemplateService_GermanTemplatesAreComplete tests that all German templates are fully translated func TestEmailTemplateService_GermanTemplatesAreComplete(t *testing.T) { service := &EmailTemplateService{} emailTypes := []string{ models.EmailTypeWelcome, models.EmailTypeEmailVerification, models.EmailTypePasswordReset, models.EmailTypePasswordChanged, models.EmailType2FAEnabled, models.EmailType2FADisabled, models.EmailTypeNewDeviceLogin, models.EmailTypeSuspiciousActivity, models.EmailTypeAccountLocked, models.EmailTypeAccountUnlocked, models.EmailTypeDeletionRequested, models.EmailTypeDeletionConfirmed, models.EmailTypeDataExportReady, models.EmailTypeEmailChanged, models.EmailTypeNewVersionPublished, models.EmailTypeConsentReminder, models.EmailTypeConsentDeadlineWarning, models.EmailTypeAccountSuspended, } germanKeywords := []string{"Hallo", "freundlichen", "Grüßen", "BreakPilot", "Ihr"} for _, emailType := range emailTypes { t.Run(emailType, func(t *testing.T) { subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(emailType, "de") // Check that German text is present foundGerman := false for _, keyword := range germanKeywords { if strings.Contains(bodyHTML, keyword) || strings.Contains(bodyText, keyword) { foundGerman = true break } } if !foundGerman { t.Errorf("Template %s does not appear to be in German", emailType) } // Check that subject is not just the fallback if subject == "No template" { t.Errorf("Template %s has fallback subject instead of German subject", emailType) } }) } } // TestEmailTemplateService_HTMLStructure tests that HTML templates have valid structure func TestEmailTemplateService_HTMLStructure(t *testing.T) { service := &EmailTemplateService{} emailTypes := []string{ models.EmailTypeWelcome, models.EmailTypeEmailVerification, models.EmailTypePasswordReset, } for _, emailType := range emailTypes { t.Run(emailType, func(t *testing.T) { _, bodyHTML, _ := service.GetDefaultTemplateContent(emailType, "de") // Check for basic HTML structure if !strings.Contains(bodyHTML, "") { t.Errorf("Template %s missing DOCTYPE", emailType) } if !strings.Contains(bodyHTML, "") { t.Errorf("Template %s missing tag", emailType) } if !strings.Contains(bodyHTML, "") { t.Errorf("Template %s missing closing tag", emailType) } if !strings.Contains(bodyHTML, " tag", emailType) } if !strings.Contains(bodyHTML, "") { t.Errorf("Template %s missing closing tag", emailType) } }) } } // BenchmarkReplaceVariables benchmarks variable replacement func BenchmarkReplaceVariables(b *testing.B) { template := "Hallo {{user_name}}, Ihr Link: {{reset_url}}, gültig bis {{expires_in}}" variables := map[string]string{ "user_name": "Max Mustermann", "reset_url": "https://example.com/reset?token=abc123", "expires_in": "24 Stunden", } for i := 0; i < b.N; i++ { replaceVariables(template, variables) } } // BenchmarkStripHTMLTags benchmarks HTML tag stripping func BenchmarkStripHTMLTags(b *testing.B) { html := "

Title

This is a test paragraph with links.

" for i := 0; i < b.N; i++ { stripHTMLTags(html) } }