| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539 |
- """
- 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"<html>Rendered {template_path} with {context}</html>"
-
-
- 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="<html>Test Content</html>",
- )
-
- 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"] == "<html>Test Content</html>"
-
- 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="<html>Test Content</html>",
- )
-
- # 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"] == "<html>Test Content</html>"
-
- 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", "<html>Content</html>")
- 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", "<html>Content</html>")
- mock_mail.send.assert_called_once_with(
- to="test@example.com",
- subject="Subject",
- html="<html>Content</html>",
- )
- finally:
- # Restore original mail
- libs.email_i18n.mail = original_mail
|