fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
624
consent-service/internal/services/email_service_test.go
Normal file
624
consent-service/internal/services/email_service_test.go
Normal file
@@ -0,0 +1,624 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// MockSMTPSender is a mock SMTP sender for testing
|
||||
type MockSMTPSender struct {
|
||||
SentEmails []SentEmail
|
||||
ShouldFail bool
|
||||
FailError error
|
||||
}
|
||||
|
||||
// SentEmail represents a sent email for testing
|
||||
type SentEmail struct {
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
// SendMail is a mock implementation of smtp.SendMail
|
||||
func (m *MockSMTPSender) SendMail(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
if m.ShouldFail {
|
||||
return m.FailError
|
||||
}
|
||||
|
||||
// Parse the email to extract subject and body
|
||||
msgStr := string(msg)
|
||||
subject := extractSubject(msgStr)
|
||||
|
||||
m.SentEmails = append(m.SentEmails, SentEmail{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: msgStr,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractSubject extracts the subject from an email message
|
||||
func extractSubject(msg string) string {
|
||||
lines := strings.Split(msg, "\r\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "Subject: ") {
|
||||
return strings.TrimPrefix(line, "Subject: ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestEmailService_SendEmail tests basic email sending
|
||||
func TestEmailService_SendEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
to string
|
||||
subject string
|
||||
htmlBody string
|
||||
textBody string
|
||||
shouldFail bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid email",
|
||||
to: "user@example.com",
|
||||
subject: "Test Email",
|
||||
htmlBody: "<h1>Hello</h1><p>World</p>",
|
||||
textBody: "Hello\nWorld",
|
||||
shouldFail: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "email with special characters",
|
||||
to: "user+test@example.com",
|
||||
subject: "Test: Öäü Special Characters",
|
||||
htmlBody: "<p>Special: €£¥</p>",
|
||||
textBody: "Special: €£¥",
|
||||
shouldFail: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "SMTP failure",
|
||||
to: "user@example.com",
|
||||
subject: "Test",
|
||||
htmlBody: "<p>Test</p>",
|
||||
textBody: "Test",
|
||||
shouldFail: true,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Validate email format
|
||||
isValidEmail := strings.Contains(tt.to, "@") && strings.Contains(tt.to, ".")
|
||||
|
||||
if !isValidEmail && !tt.expectError {
|
||||
t.Error("Invalid email format should produce error")
|
||||
}
|
||||
|
||||
// Validate subject is not empty
|
||||
if tt.subject == "" && !tt.expectError {
|
||||
t.Error("Empty subject should produce error")
|
||||
}
|
||||
|
||||
// Validate body content exists
|
||||
if (tt.htmlBody == "" && tt.textBody == "") && !tt.expectError {
|
||||
t.Error("Both bodies empty should produce error")
|
||||
}
|
||||
|
||||
// Simulate SMTP send
|
||||
var err error
|
||||
if tt.shouldFail {
|
||||
err = fmt.Errorf("SMTP error: connection refused")
|
||||
}
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_SendVerificationEmail tests verification email sending
|
||||
func TestEmailService_SendVerificationEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
to string
|
||||
userName string
|
||||
token string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid verification email",
|
||||
to: "newuser@example.com",
|
||||
userName: "Max Mustermann",
|
||||
token: "abc123def456",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "user without name",
|
||||
to: "user@example.com",
|
||||
userName: "",
|
||||
token: "token123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
to: "user@example.com",
|
||||
userName: "Test User",
|
||||
token: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
to: "invalid-email",
|
||||
userName: "Test",
|
||||
token: "token123",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Validate inputs
|
||||
var err error
|
||||
if tt.token == "" {
|
||||
err = &ValidationError{Field: "token", Message: "required"}
|
||||
} else if !strings.Contains(tt.to, "@") {
|
||||
err = &ValidationError{Field: "email", Message: "invalid format"}
|
||||
}
|
||||
|
||||
// Build verification link
|
||||
if tt.token != "" {
|
||||
verifyLink := fmt.Sprintf("https://example.com/verify-email?token=%s", tt.token)
|
||||
if verifyLink == "" {
|
||||
t.Error("Verification link should not be empty")
|
||||
}
|
||||
|
||||
// Verify link contains token
|
||||
if !strings.Contains(verifyLink, tt.token) {
|
||||
t.Error("Verification link should contain token")
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_SendPasswordResetEmail tests password reset email
|
||||
func TestEmailService_SendPasswordResetEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
to string
|
||||
userName string
|
||||
token string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid password reset",
|
||||
to: "user@example.com",
|
||||
userName: "John Doe",
|
||||
token: "reset-token-123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
to: "user@example.com",
|
||||
userName: "John Doe",
|
||||
token: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error
|
||||
if tt.token == "" {
|
||||
err = &ValidationError{Field: "token", Message: "required"}
|
||||
}
|
||||
|
||||
// Build reset link
|
||||
if tt.token != "" {
|
||||
resetLink := fmt.Sprintf("https://example.com/reset-password?token=%s", tt.token)
|
||||
|
||||
// Verify link is secure (HTTPS)
|
||||
if !strings.HasPrefix(resetLink, "https://") {
|
||||
t.Error("Reset link should use HTTPS")
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_Send2FAEmail tests 2FA notification emails
|
||||
func TestEmailService_Send2FAEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
to string
|
||||
action string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "2FA enabled notification",
|
||||
to: "user@example.com",
|
||||
action: "enabled",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "2FA disabled notification",
|
||||
to: "user@example.com",
|
||||
action: "disabled",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid action",
|
||||
to: "user@example.com",
|
||||
action: "invalid",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
validActions := map[string]bool{
|
||||
"enabled": true,
|
||||
"disabled": true,
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error
|
||||
if !validActions[tt.action] {
|
||||
err = &ValidationError{Field: "action", Message: "must be 'enabled' or 'disabled'"}
|
||||
}
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_SendConsentReminderEmail tests consent reminder
|
||||
func TestEmailService_SendConsentReminderEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
to string
|
||||
documentName string
|
||||
daysLeft int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "reminder with 7 days left",
|
||||
to: "user@example.com",
|
||||
documentName: "Terms of Service",
|
||||
daysLeft: 7,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "reminder with 1 day left",
|
||||
to: "user@example.com",
|
||||
documentName: "Privacy Policy",
|
||||
daysLeft: 1,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "urgent reminder - overdue",
|
||||
to: "user@example.com",
|
||||
documentName: "Terms",
|
||||
daysLeft: 0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty document name",
|
||||
to: "user@example.com",
|
||||
documentName: "",
|
||||
daysLeft: 7,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error
|
||||
if tt.documentName == "" {
|
||||
err = &ValidationError{Field: "document name", Message: "required"}
|
||||
}
|
||||
|
||||
// Check urgency level
|
||||
var urgency string
|
||||
if tt.daysLeft <= 0 {
|
||||
urgency = "critical"
|
||||
} else if tt.daysLeft <= 3 {
|
||||
urgency = "urgent"
|
||||
} else {
|
||||
urgency = "normal"
|
||||
}
|
||||
|
||||
if urgency == "" && !tt.expectError {
|
||||
t.Error("Urgency should be set")
|
||||
}
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_MIMEFormatting tests MIME message formatting
|
||||
func TestEmailService_MIMEFormatting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
htmlBody string
|
||||
textBody string
|
||||
checkFor []string
|
||||
}{
|
||||
{
|
||||
name: "multipart alternative",
|
||||
htmlBody: "<h1>Test</h1>",
|
||||
textBody: "Test",
|
||||
checkFor: []string{
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative",
|
||||
"Content-Type: text/plain",
|
||||
"Content-Type: text/html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UTF-8 encoding",
|
||||
htmlBody: "<p>Öäü</p>",
|
||||
textBody: "Öäü",
|
||||
checkFor: []string{
|
||||
"charset=\"UTF-8\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Build MIME message (simplified)
|
||||
message := fmt.Sprintf("MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: multipart/alternative; boundary=\"boundary\"\r\n"+
|
||||
"\r\n"+
|
||||
"--boundary\r\n"+
|
||||
"Content-Type: text/plain; charset=\"UTF-8\"\r\n"+
|
||||
"\r\n%s\r\n"+
|
||||
"--boundary\r\n"+
|
||||
"Content-Type: text/html; charset=\"UTF-8\"\r\n"+
|
||||
"\r\n%s\r\n"+
|
||||
"--boundary--\r\n",
|
||||
tt.textBody, tt.htmlBody)
|
||||
|
||||
// Verify required headers are present
|
||||
for _, required := range tt.checkFor {
|
||||
if !strings.Contains(message, required) {
|
||||
t.Errorf("Message should contain '%s'", required)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify both bodies are included
|
||||
if !strings.Contains(message, tt.textBody) {
|
||||
t.Error("Message should contain text body")
|
||||
}
|
||||
if !strings.Contains(message, tt.htmlBody) {
|
||||
t.Error("Message should contain HTML body")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_TemplateRendering tests email template rendering
|
||||
func TestEmailService_TemplateRendering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
variables map[string]string
|
||||
expectVars []string
|
||||
}{
|
||||
{
|
||||
name: "verification template",
|
||||
template: "verification",
|
||||
variables: map[string]string{
|
||||
"Name": "John Doe",
|
||||
"VerifyLink": "https://example.com/verify?token=abc",
|
||||
},
|
||||
expectVars: []string{"John Doe", "https://example.com/verify?token=abc"},
|
||||
},
|
||||
{
|
||||
name: "password reset template",
|
||||
template: "password_reset",
|
||||
variables: map[string]string{
|
||||
"Name": "Jane Smith",
|
||||
"ResetLink": "https://example.com/reset?token=xyz",
|
||||
},
|
||||
expectVars: []string{"Jane Smith", "https://example.com/reset?token=xyz"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate template rendering
|
||||
rendered := fmt.Sprintf("Hello %s, please visit %s",
|
||||
tt.variables["Name"],
|
||||
getLink(tt.variables))
|
||||
|
||||
// Verify all variables are in rendered output
|
||||
for _, expectedVar := range tt.expectVars {
|
||||
if !strings.Contains(rendered, expectedVar) {
|
||||
t.Errorf("Rendered template should contain '%s'", expectedVar)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_EmailValidation tests email address validation
|
||||
func TestEmailService_EmailValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
email string
|
||||
isValid bool
|
||||
}{
|
||||
{"user@example.com", true},
|
||||
{"user+tag@example.com", true},
|
||||
{"user.name@example.co.uk", true},
|
||||
{"user@subdomain.example.com", true},
|
||||
{"invalid", false},
|
||||
{"@example.com", false},
|
||||
{"user@", false},
|
||||
{"user@.com", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.email, func(t *testing.T) {
|
||||
// RFC 5322 compliant email validation pattern
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
isValid := emailRegex.MatchString(tt.email)
|
||||
|
||||
if isValid != tt.isValid {
|
||||
t.Errorf("Email %s: expected valid=%v, got %v", tt.email, tt.isValid, isValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_SMTPConfig tests SMTP configuration
|
||||
func TestEmailService_SMTPConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config EmailConfig
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: EmailConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
FromName: "BreakPilot",
|
||||
FromAddr: "noreply@example.com",
|
||||
BaseURL: "https://example.com",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "missing host",
|
||||
config: EmailConfig{
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid port",
|
||||
config: EmailConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 0,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error
|
||||
if tt.config.Host == "" {
|
||||
err = &ValidationError{Field: "host", Message: "required"}
|
||||
} else if tt.config.Port <= 0 || tt.config.Port > 65535 {
|
||||
err = &ValidationError{Field: "port", Message: "must be between 1 and 65535"}
|
||||
}
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailService_RateLimiting tests email rate limiting logic
|
||||
func TestEmailService_RateLimiting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
emailsSent int
|
||||
timeWindow int // minutes
|
||||
limit int
|
||||
expectThrottle bool
|
||||
}{
|
||||
{
|
||||
name: "under limit",
|
||||
emailsSent: 5,
|
||||
timeWindow: 60,
|
||||
limit: 10,
|
||||
expectThrottle: false,
|
||||
},
|
||||
{
|
||||
name: "at limit",
|
||||
emailsSent: 10,
|
||||
timeWindow: 60,
|
||||
limit: 10,
|
||||
expectThrottle: false,
|
||||
},
|
||||
{
|
||||
name: "over limit",
|
||||
emailsSent: 15,
|
||||
timeWindow: 60,
|
||||
limit: 10,
|
||||
expectThrottle: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
shouldThrottle := tt.emailsSent > tt.limit
|
||||
|
||||
if shouldThrottle != tt.expectThrottle {
|
||||
t.Errorf("Expected throttle=%v, got %v", tt.expectThrottle, shouldThrottle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getLink(vars map[string]string) string {
|
||||
if link, ok := vars["VerifyLink"]; ok {
|
||||
return link
|
||||
}
|
||||
if link, ok := vars["ResetLink"]; ok {
|
||||
return link
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user