""" Unit tests for EmailI18nService Tests the email internationalization service with mocked dependencies following Domain-Driven Design principles. """ from typing import Any from unittest.mock import MagicMock import pytest from libs.email_i18n import ( EmailI18nConfig, EmailI18nService, EmailLanguage, EmailTemplate, EmailType, FlaskEmailRenderer, FlaskMailSender, create_default_email_config, get_email_i18n_service, ) from services.feature_service import BrandingModel class MockEmailRenderer: """Mock implementation of EmailRenderer protocol""" def __init__(self) -> None: self.rendered_templates: list[tuple[str, dict[str, Any]]] = [] def render_template(self, template_path: str, **context: Any) -> str: """Mock render_template that returns a formatted string""" self.rendered_templates.append((template_path, context)) return f"Rendered {template_path} with {context}" class MockBrandingService: """Mock implementation of BrandingService protocol""" def __init__(self, enabled: bool = False, application_title: str = "Dify") -> None: self.enabled = enabled self.application_title = application_title def get_branding_config(self) -> BrandingModel: """Return mock branding configuration""" branding_model = MagicMock(spec=BrandingModel) branding_model.enabled = self.enabled branding_model.application_title = self.application_title return branding_model class MockEmailSender: """Mock implementation of EmailSender protocol""" def __init__(self) -> None: self.sent_emails: list[dict[str, str]] = [] def send_email(self, to: str, subject: str, html_content: str) -> None: """Mock send_email that records sent emails""" self.sent_emails.append( { "to": to, "subject": subject, "html_content": html_content, } ) class TestEmailI18nService: """Test cases for EmailI18nService""" @pytest.fixture def email_config(self) -> EmailI18nConfig: """Create test email configuration""" return EmailI18nConfig( templates={ EmailType.RESET_PASSWORD: { EmailLanguage.EN_US: EmailTemplate( subject="Reset Your {application_title} Password", template_path="reset_password_en.html", branded_template_path="branded/reset_password_en.html", ), EmailLanguage.ZH_HANS: EmailTemplate( subject="重置您的 {application_title} 密码", template_path="reset_password_zh.html", branded_template_path="branded/reset_password_zh.html", ), }, EmailType.INVITE_MEMBER: { EmailLanguage.EN_US: EmailTemplate( subject="Join {application_title} Workspace", template_path="invite_member_en.html", branded_template_path="branded/invite_member_en.html", ), }, } ) @pytest.fixture def mock_renderer(self) -> MockEmailRenderer: """Create mock email renderer""" return MockEmailRenderer() @pytest.fixture def mock_branding_service(self) -> MockBrandingService: """Create mock branding service""" return MockBrandingService() @pytest.fixture def mock_sender(self) -> MockEmailSender: """Create mock email sender""" return MockEmailSender() @pytest.fixture def email_service( self, email_config: EmailI18nConfig, mock_renderer: MockEmailRenderer, mock_branding_service: MockBrandingService, mock_sender: MockEmailSender, ) -> EmailI18nService: """Create EmailI18nService with mocked dependencies""" return EmailI18nService( config=email_config, renderer=mock_renderer, branding_service=mock_branding_service, sender=mock_sender, ) def test_send_email_with_english_language( self, email_service: EmailI18nService, mock_renderer: MockEmailRenderer, mock_sender: MockEmailSender, ) -> None: """Test sending email with English language""" email_service.send_email( email_type=EmailType.RESET_PASSWORD, language_code="en-US", to="test@example.com", template_context={"reset_link": "https://example.com/reset"}, ) # Verify renderer was called with correct template assert len(mock_renderer.rendered_templates) == 1 template_path, context = mock_renderer.rendered_templates[0] assert template_path == "reset_password_en.html" assert context["reset_link"] == "https://example.com/reset" assert context["branding_enabled"] is False assert context["application_title"] == "Dify" # Verify email was sent assert len(mock_sender.sent_emails) == 1 sent_email = mock_sender.sent_emails[0] assert sent_email["to"] == "test@example.com" assert sent_email["subject"] == "Reset Your Dify Password" assert "reset_password_en.html" in sent_email["html_content"] def test_send_email_with_chinese_language( self, email_service: EmailI18nService, mock_sender: MockEmailSender, ) -> None: """Test sending email with Chinese language""" email_service.send_email( email_type=EmailType.RESET_PASSWORD, language_code="zh-Hans", to="test@example.com", template_context={"reset_link": "https://example.com/reset"}, ) # Verify email was sent with Chinese subject assert len(mock_sender.sent_emails) == 1 sent_email = mock_sender.sent_emails[0] assert sent_email["subject"] == "重置您的 Dify 密码" def test_send_email_with_branding_enabled( self, email_config: EmailI18nConfig, mock_renderer: MockEmailRenderer, mock_sender: MockEmailSender, ) -> None: """Test sending email with branding enabled""" # Create branding service with branding enabled branding_service = MockBrandingService(enabled=True, application_title="MyApp") email_service = EmailI18nService( config=email_config, renderer=mock_renderer, branding_service=branding_service, sender=mock_sender, ) email_service.send_email( email_type=EmailType.RESET_PASSWORD, language_code="en-US", to="test@example.com", ) # Verify branded template was used assert len(mock_renderer.rendered_templates) == 1 template_path, context = mock_renderer.rendered_templates[0] assert template_path == "branded/reset_password_en.html" assert context["branding_enabled"] is True assert context["application_title"] == "MyApp" # Verify subject includes custom application title assert len(mock_sender.sent_emails) == 1 sent_email = mock_sender.sent_emails[0] assert sent_email["subject"] == "Reset Your MyApp Password" def test_send_email_with_language_fallback( self, email_service: EmailI18nService, mock_sender: MockEmailSender, ) -> None: """Test language fallback to English when requested language not available""" # Request invite member in Chinese (not configured) email_service.send_email( email_type=EmailType.INVITE_MEMBER, language_code="zh-Hans", to="test@example.com", ) # Should fall back to English assert len(mock_sender.sent_emails) == 1 sent_email = mock_sender.sent_emails[0] assert sent_email["subject"] == "Join Dify Workspace" def test_send_email_with_unknown_language_code( self, email_service: EmailI18nService, mock_sender: MockEmailSender, ) -> None: """Test unknown language code falls back to English""" email_service.send_email( email_type=EmailType.RESET_PASSWORD, language_code="fr-FR", # French not configured to="test@example.com", ) # Should use English assert len(mock_sender.sent_emails) == 1 sent_email = mock_sender.sent_emails[0] assert sent_email["subject"] == "Reset Your Dify Password" def test_send_change_email_old_phase( self, email_config: EmailI18nConfig, mock_renderer: MockEmailRenderer, mock_sender: MockEmailSender, mock_branding_service: MockBrandingService, ) -> None: """Test sending change email for old email verification""" # Add change email templates to config email_config.templates[EmailType.CHANGE_EMAIL_OLD] = { EmailLanguage.EN_US: EmailTemplate( subject="Verify your current email", template_path="change_email_old_en.html", branded_template_path="branded/change_email_old_en.html", ), } email_service = EmailI18nService( config=email_config, renderer=mock_renderer, branding_service=mock_branding_service, sender=mock_sender, ) email_service.send_change_email( language_code="en-US", to="old@example.com", code="123456", phase="old_email", ) # Verify correct template and context assert len(mock_renderer.rendered_templates) == 1 template_path, context = mock_renderer.rendered_templates[0] assert template_path == "change_email_old_en.html" assert context["to"] == "old@example.com" assert context["code"] == "123456" def test_send_change_email_new_phase( self, email_config: EmailI18nConfig, mock_renderer: MockEmailRenderer, mock_sender: MockEmailSender, mock_branding_service: MockBrandingService, ) -> None: """Test sending change email for new email verification""" # Add change email templates to config email_config.templates[EmailType.CHANGE_EMAIL_NEW] = { EmailLanguage.EN_US: EmailTemplate( subject="Verify your new email", template_path="change_email_new_en.html", branded_template_path="branded/change_email_new_en.html", ), } email_service = EmailI18nService( config=email_config, renderer=mock_renderer, branding_service=mock_branding_service, sender=mock_sender, ) email_service.send_change_email( language_code="en-US", to="new@example.com", code="654321", phase="new_email", ) # Verify correct template and context assert len(mock_renderer.rendered_templates) == 1 template_path, context = mock_renderer.rendered_templates[0] assert template_path == "change_email_new_en.html" assert context["to"] == "new@example.com" assert context["code"] == "654321" def test_send_change_email_invalid_phase( self, email_service: EmailI18nService, ) -> None: """Test sending change email with invalid phase raises error""" with pytest.raises(ValueError, match="Invalid phase: invalid_phase"): email_service.send_change_email( language_code="en-US", to="test@example.com", code="123456", phase="invalid_phase", ) def test_send_raw_email_single_recipient( self, email_service: EmailI18nService, mock_sender: MockEmailSender, ) -> None: """Test sending raw email to single recipient""" email_service.send_raw_email( to="test@example.com", subject="Test Subject", html_content="Test Content", ) assert len(mock_sender.sent_emails) == 1 sent_email = mock_sender.sent_emails[0] assert sent_email["to"] == "test@example.com" assert sent_email["subject"] == "Test Subject" assert sent_email["html_content"] == "Test Content" def test_send_raw_email_multiple_recipients( self, email_service: EmailI18nService, mock_sender: MockEmailSender, ) -> None: """Test sending raw email to multiple recipients""" recipients = ["user1@example.com", "user2@example.com", "user3@example.com"] email_service.send_raw_email( to=recipients, subject="Test Subject", html_content="Test Content", ) # Should send individual emails to each recipient assert len(mock_sender.sent_emails) == 3 for i, recipient in enumerate(recipients): sent_email = mock_sender.sent_emails[i] assert sent_email["to"] == recipient assert sent_email["subject"] == "Test Subject" assert sent_email["html_content"] == "Test Content" def test_get_template_missing_email_type( self, email_config: EmailI18nConfig, ) -> None: """Test getting template for missing email type raises error""" with pytest.raises(ValueError, match="No templates configured for email type"): email_config.get_template(EmailType.EMAIL_CODE_LOGIN, EmailLanguage.EN_US) def test_get_template_missing_language_and_english( self, email_config: EmailI18nConfig, ) -> None: """Test error when neither requested language nor English fallback exists""" # Add template without English fallback email_config.templates[EmailType.EMAIL_CODE_LOGIN] = { EmailLanguage.ZH_HANS: EmailTemplate( subject="Test", template_path="test.html", branded_template_path="branded/test.html", ), } with pytest.raises(ValueError, match="No template found for"): # Request a language that doesn't exist and no English fallback email_config.get_template(EmailType.EMAIL_CODE_LOGIN, EmailLanguage.EN_US) def test_subject_templating_with_variables( self, email_config: EmailI18nConfig, mock_renderer: MockEmailRenderer, mock_sender: MockEmailSender, mock_branding_service: MockBrandingService, ) -> None: """Test subject templating with custom variables""" # Add template with variable in subject email_config.templates[EmailType.OWNER_TRANSFER_NEW_NOTIFY] = { EmailLanguage.EN_US: EmailTemplate( subject="You are now the owner of {WorkspaceName}", template_path="owner_transfer_en.html", branded_template_path="branded/owner_transfer_en.html", ), } email_service = EmailI18nService( config=email_config, renderer=mock_renderer, branding_service=mock_branding_service, sender=mock_sender, ) email_service.send_email( email_type=EmailType.OWNER_TRANSFER_NEW_NOTIFY, language_code="en-US", to="test@example.com", template_context={"WorkspaceName": "My Workspace"}, ) # Verify subject was templated correctly assert len(mock_sender.sent_emails) == 1 sent_email = mock_sender.sent_emails[0] assert sent_email["subject"] == "You are now the owner of My Workspace" def test_email_language_from_language_code(self) -> None: """Test EmailLanguage.from_language_code method""" assert EmailLanguage.from_language_code("zh-Hans") == EmailLanguage.ZH_HANS assert EmailLanguage.from_language_code("en-US") == EmailLanguage.EN_US assert EmailLanguage.from_language_code("fr-FR") == EmailLanguage.EN_US # Fallback assert EmailLanguage.from_language_code("unknown") == EmailLanguage.EN_US # Fallback class TestEmailI18nIntegration: """Integration tests for email i18n components""" def test_create_default_email_config(self) -> None: """Test creating default email configuration""" config = create_default_email_config() # Verify key email types have at least English template expected_types = [ EmailType.RESET_PASSWORD, EmailType.INVITE_MEMBER, EmailType.EMAIL_CODE_LOGIN, EmailType.CHANGE_EMAIL_OLD, EmailType.CHANGE_EMAIL_NEW, EmailType.OWNER_TRANSFER_CONFIRM, EmailType.OWNER_TRANSFER_OLD_NOTIFY, EmailType.OWNER_TRANSFER_NEW_NOTIFY, EmailType.ACCOUNT_DELETION_SUCCESS, EmailType.ACCOUNT_DELETION_VERIFICATION, EmailType.QUEUE_MONITOR_ALERT, EmailType.DOCUMENT_CLEAN_NOTIFY, ] for email_type in expected_types: assert email_type in config.templates assert EmailLanguage.EN_US in config.templates[email_type] # Verify some have Chinese translations assert EmailLanguage.ZH_HANS in config.templates[EmailType.RESET_PASSWORD] assert EmailLanguage.ZH_HANS in config.templates[EmailType.INVITE_MEMBER] def test_get_email_i18n_service(self) -> None: """Test getting global email i18n service instance""" service1 = get_email_i18n_service() service2 = get_email_i18n_service() # Should return the same instance assert service1 is service2 def test_flask_email_renderer(self) -> None: """Test FlaskEmailRenderer implementation""" renderer = FlaskEmailRenderer() # Should raise TemplateNotFound when template doesn't exist from jinja2.exceptions import TemplateNotFound with pytest.raises(TemplateNotFound): renderer.render_template("test.html", foo="bar") def test_flask_mail_sender_not_initialized(self) -> None: """Test FlaskMailSender when mail is not initialized""" sender = FlaskMailSender() # Mock mail.is_inited() to return False import libs.email_i18n original_mail = libs.email_i18n.mail mock_mail = MagicMock() mock_mail.is_inited.return_value = False libs.email_i18n.mail = mock_mail try: # Should not send email when mail is not initialized sender.send_email("test@example.com", "Subject", "Content") mock_mail.send.assert_not_called() finally: # Restore original mail libs.email_i18n.mail = original_mail def test_flask_mail_sender_initialized(self) -> None: """Test FlaskMailSender when mail is initialized""" sender = FlaskMailSender() # Mock mail.is_inited() to return True import libs.email_i18n original_mail = libs.email_i18n.mail mock_mail = MagicMock() mock_mail.is_inited.return_value = True libs.email_i18n.mail = mock_mail try: # Should send email when mail is initialized sender.send_email("test@example.com", "Subject", "Content") mock_mail.send.assert_called_once_with( to="test@example.com", subject="Subject", html="Content", ) finally: # Restore original mail libs.email_i18n.mail = original_mail