From a82d75bf60fb1fd445ec3add04ec7c1fe66c00e2 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 22 Jul 2025 10:51:37 +0800 Subject: [PATCH] test: add comprehensive unit tests for EmailI18nService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created comprehensive test suite with 19 test cases covering all EmailI18nService functionality - Implemented mock classes (MockEmailRenderer, MockBrandingService, MockEmailSender) following protocol pattern - Avoided using patch decorator, instead using constructor dependency injection for cleaner testing - Tests cover: * Email sending with different languages (English, Chinese, fallback handling) * Branding enabled/disabled scenarios * Change email workflows (old/new phases) * Raw email sending (single and multiple recipients) * Error handling for invalid phases and missing templates * Subject templating with variables * Integration tests for default configuration and service creation * Flask-specific components (renderer and sender) The tests follow DDD principles with proper separation of concerns and use dependency injection rather than mocking frameworks for cleaner, more maintainable tests. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- api/tests/unit_tests/libs/test_email_i18n.py | 539 +++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 api/tests/unit_tests/libs/test_email_i18n.py diff --git a/api/tests/unit_tests/libs/test_email_i18n.py b/api/tests/unit_tests/libs/test_email_i18n.py new file mode 100644 index 0000000000..aeb30438e0 --- /dev/null +++ b/api/tests/unit_tests/libs/test_email_i18n.py @@ -0,0 +1,539 @@ +""" +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