Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

test_email_i18n.py 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. """
  2. Unit tests for EmailI18nService
  3. Tests the email internationalization service with mocked dependencies
  4. following Domain-Driven Design principles.
  5. """
  6. from typing import Any
  7. from unittest.mock import MagicMock
  8. import pytest
  9. from libs.email_i18n import (
  10. EmailI18nConfig,
  11. EmailI18nService,
  12. EmailLanguage,
  13. EmailTemplate,
  14. EmailType,
  15. FlaskEmailRenderer,
  16. FlaskMailSender,
  17. create_default_email_config,
  18. get_email_i18n_service,
  19. )
  20. from services.feature_service import BrandingModel
  21. class MockEmailRenderer:
  22. """Mock implementation of EmailRenderer protocol"""
  23. def __init__(self):
  24. self.rendered_templates: list[tuple[str, dict[str, Any]]] = []
  25. def render_template(self, template_path: str, **context: Any) -> str:
  26. """Mock render_template that returns a formatted string"""
  27. self.rendered_templates.append((template_path, context))
  28. return f"<html>Rendered {template_path} with {context}</html>"
  29. class MockBrandingService:
  30. """Mock implementation of BrandingService protocol"""
  31. def __init__(self, enabled: bool = False, application_title: str = "Dify"):
  32. self.enabled = enabled
  33. self.application_title = application_title
  34. def get_branding_config(self) -> BrandingModel:
  35. """Return mock branding configuration"""
  36. branding_model = MagicMock(spec=BrandingModel)
  37. branding_model.enabled = self.enabled
  38. branding_model.application_title = self.application_title
  39. return branding_model
  40. class MockEmailSender:
  41. """Mock implementation of EmailSender protocol"""
  42. def __init__(self):
  43. self.sent_emails: list[dict[str, str]] = []
  44. def send_email(self, to: str, subject: str, html_content: str):
  45. """Mock send_email that records sent emails"""
  46. self.sent_emails.append(
  47. {
  48. "to": to,
  49. "subject": subject,
  50. "html_content": html_content,
  51. }
  52. )
  53. class TestEmailI18nService:
  54. """Test cases for EmailI18nService"""
  55. @pytest.fixture
  56. def email_config(self) -> EmailI18nConfig:
  57. """Create test email configuration"""
  58. return EmailI18nConfig(
  59. templates={
  60. EmailType.RESET_PASSWORD: {
  61. EmailLanguage.EN_US: EmailTemplate(
  62. subject="Reset Your {application_title} Password",
  63. template_path="reset_password_en.html",
  64. branded_template_path="branded/reset_password_en.html",
  65. ),
  66. EmailLanguage.ZH_HANS: EmailTemplate(
  67. subject="重置您的 {application_title} 密码",
  68. template_path="reset_password_zh.html",
  69. branded_template_path="branded/reset_password_zh.html",
  70. ),
  71. },
  72. EmailType.INVITE_MEMBER: {
  73. EmailLanguage.EN_US: EmailTemplate(
  74. subject="Join {application_title} Workspace",
  75. template_path="invite_member_en.html",
  76. branded_template_path="branded/invite_member_en.html",
  77. ),
  78. },
  79. }
  80. )
  81. @pytest.fixture
  82. def mock_renderer(self) -> MockEmailRenderer:
  83. """Create mock email renderer"""
  84. return MockEmailRenderer()
  85. @pytest.fixture
  86. def mock_branding_service(self) -> MockBrandingService:
  87. """Create mock branding service"""
  88. return MockBrandingService()
  89. @pytest.fixture
  90. def mock_sender(self) -> MockEmailSender:
  91. """Create mock email sender"""
  92. return MockEmailSender()
  93. @pytest.fixture
  94. def email_service(
  95. self,
  96. email_config: EmailI18nConfig,
  97. mock_renderer: MockEmailRenderer,
  98. mock_branding_service: MockBrandingService,
  99. mock_sender: MockEmailSender,
  100. ) -> EmailI18nService:
  101. """Create EmailI18nService with mocked dependencies"""
  102. return EmailI18nService(
  103. config=email_config,
  104. renderer=mock_renderer,
  105. branding_service=mock_branding_service,
  106. sender=mock_sender,
  107. )
  108. def test_send_email_with_english_language(
  109. self,
  110. email_service: EmailI18nService,
  111. mock_renderer: MockEmailRenderer,
  112. mock_sender: MockEmailSender,
  113. ):
  114. """Test sending email with English language"""
  115. email_service.send_email(
  116. email_type=EmailType.RESET_PASSWORD,
  117. language_code="en-US",
  118. to="test@example.com",
  119. template_context={"reset_link": "https://example.com/reset"},
  120. )
  121. # Verify renderer was called with correct template
  122. assert len(mock_renderer.rendered_templates) == 1
  123. template_path, context = mock_renderer.rendered_templates[0]
  124. assert template_path == "reset_password_en.html"
  125. assert context["reset_link"] == "https://example.com/reset"
  126. assert context["branding_enabled"] is False
  127. assert context["application_title"] == "Dify"
  128. # Verify email was sent
  129. assert len(mock_sender.sent_emails) == 1
  130. sent_email = mock_sender.sent_emails[0]
  131. assert sent_email["to"] == "test@example.com"
  132. assert sent_email["subject"] == "Reset Your Dify Password"
  133. assert "reset_password_en.html" in sent_email["html_content"]
  134. def test_send_email_with_chinese_language(
  135. self,
  136. email_service: EmailI18nService,
  137. mock_sender: MockEmailSender,
  138. ):
  139. """Test sending email with Chinese language"""
  140. email_service.send_email(
  141. email_type=EmailType.RESET_PASSWORD,
  142. language_code="zh-Hans",
  143. to="test@example.com",
  144. template_context={"reset_link": "https://example.com/reset"},
  145. )
  146. # Verify email was sent with Chinese subject
  147. assert len(mock_sender.sent_emails) == 1
  148. sent_email = mock_sender.sent_emails[0]
  149. assert sent_email["subject"] == "重置您的 Dify 密码"
  150. def test_send_email_with_branding_enabled(
  151. self,
  152. email_config: EmailI18nConfig,
  153. mock_renderer: MockEmailRenderer,
  154. mock_sender: MockEmailSender,
  155. ):
  156. """Test sending email with branding enabled"""
  157. # Create branding service with branding enabled
  158. branding_service = MockBrandingService(enabled=True, application_title="MyApp")
  159. email_service = EmailI18nService(
  160. config=email_config,
  161. renderer=mock_renderer,
  162. branding_service=branding_service,
  163. sender=mock_sender,
  164. )
  165. email_service.send_email(
  166. email_type=EmailType.RESET_PASSWORD,
  167. language_code="en-US",
  168. to="test@example.com",
  169. )
  170. # Verify branded template was used
  171. assert len(mock_renderer.rendered_templates) == 1
  172. template_path, context = mock_renderer.rendered_templates[0]
  173. assert template_path == "branded/reset_password_en.html"
  174. assert context["branding_enabled"] is True
  175. assert context["application_title"] == "MyApp"
  176. # Verify subject includes custom application title
  177. assert len(mock_sender.sent_emails) == 1
  178. sent_email = mock_sender.sent_emails[0]
  179. assert sent_email["subject"] == "Reset Your MyApp Password"
  180. def test_send_email_with_language_fallback(
  181. self,
  182. email_service: EmailI18nService,
  183. mock_sender: MockEmailSender,
  184. ):
  185. """Test language fallback to English when requested language not available"""
  186. # Request invite member in Chinese (not configured)
  187. email_service.send_email(
  188. email_type=EmailType.INVITE_MEMBER,
  189. language_code="zh-Hans",
  190. to="test@example.com",
  191. )
  192. # Should fall back to English
  193. assert len(mock_sender.sent_emails) == 1
  194. sent_email = mock_sender.sent_emails[0]
  195. assert sent_email["subject"] == "Join Dify Workspace"
  196. def test_send_email_with_unknown_language_code(
  197. self,
  198. email_service: EmailI18nService,
  199. mock_sender: MockEmailSender,
  200. ):
  201. """Test unknown language code falls back to English"""
  202. email_service.send_email(
  203. email_type=EmailType.RESET_PASSWORD,
  204. language_code="fr-FR", # French not configured
  205. to="test@example.com",
  206. )
  207. # Should use English
  208. assert len(mock_sender.sent_emails) == 1
  209. sent_email = mock_sender.sent_emails[0]
  210. assert sent_email["subject"] == "Reset Your Dify Password"
  211. def test_subject_format_keyerror_fallback_path(
  212. self,
  213. mock_renderer: MockEmailRenderer,
  214. mock_sender: MockEmailSender,
  215. ):
  216. """Trigger subject KeyError and cover except branch."""
  217. # Config with subject that references an unknown key (no {application_title} to avoid second format)
  218. config = EmailI18nConfig(
  219. templates={
  220. EmailType.INVITE_MEMBER: {
  221. EmailLanguage.EN_US: EmailTemplate(
  222. subject="Invite: {unknown_placeholder}",
  223. template_path="invite_member_en.html",
  224. branded_template_path="branded/invite_member_en.html",
  225. ),
  226. }
  227. }
  228. )
  229. branding_service = MockBrandingService(enabled=False)
  230. service = EmailI18nService(
  231. config=config,
  232. renderer=mock_renderer,
  233. branding_service=branding_service,
  234. sender=mock_sender,
  235. )
  236. # Will raise KeyError on subject.format(**full_context), then hit except branch and skip fallback
  237. service.send_email(
  238. email_type=EmailType.INVITE_MEMBER,
  239. language_code="en-US",
  240. to="test@example.com",
  241. )
  242. assert len(mock_sender.sent_emails) == 1
  243. # Subject is left unformatted due to KeyError fallback path without application_title
  244. assert mock_sender.sent_emails[0]["subject"] == "Invite: {unknown_placeholder}"
  245. def test_send_change_email_old_phase(
  246. self,
  247. email_config: EmailI18nConfig,
  248. mock_renderer: MockEmailRenderer,
  249. mock_sender: MockEmailSender,
  250. mock_branding_service: MockBrandingService,
  251. ):
  252. """Test sending change email for old email verification"""
  253. # Add change email templates to config
  254. email_config.templates[EmailType.CHANGE_EMAIL_OLD] = {
  255. EmailLanguage.EN_US: EmailTemplate(
  256. subject="Verify your current email",
  257. template_path="change_email_old_en.html",
  258. branded_template_path="branded/change_email_old_en.html",
  259. ),
  260. }
  261. email_service = EmailI18nService(
  262. config=email_config,
  263. renderer=mock_renderer,
  264. branding_service=mock_branding_service,
  265. sender=mock_sender,
  266. )
  267. email_service.send_change_email(
  268. language_code="en-US",
  269. to="old@example.com",
  270. code="123456",
  271. phase="old_email",
  272. )
  273. # Verify correct template and context
  274. assert len(mock_renderer.rendered_templates) == 1
  275. template_path, context = mock_renderer.rendered_templates[0]
  276. assert template_path == "change_email_old_en.html"
  277. assert context["to"] == "old@example.com"
  278. assert context["code"] == "123456"
  279. def test_send_change_email_new_phase(
  280. self,
  281. email_config: EmailI18nConfig,
  282. mock_renderer: MockEmailRenderer,
  283. mock_sender: MockEmailSender,
  284. mock_branding_service: MockBrandingService,
  285. ):
  286. """Test sending change email for new email verification"""
  287. # Add change email templates to config
  288. email_config.templates[EmailType.CHANGE_EMAIL_NEW] = {
  289. EmailLanguage.EN_US: EmailTemplate(
  290. subject="Verify your new email",
  291. template_path="change_email_new_en.html",
  292. branded_template_path="branded/change_email_new_en.html",
  293. ),
  294. }
  295. email_service = EmailI18nService(
  296. config=email_config,
  297. renderer=mock_renderer,
  298. branding_service=mock_branding_service,
  299. sender=mock_sender,
  300. )
  301. email_service.send_change_email(
  302. language_code="en-US",
  303. to="new@example.com",
  304. code="654321",
  305. phase="new_email",
  306. )
  307. # Verify correct template and context
  308. assert len(mock_renderer.rendered_templates) == 1
  309. template_path, context = mock_renderer.rendered_templates[0]
  310. assert template_path == "change_email_new_en.html"
  311. assert context["to"] == "new@example.com"
  312. assert context["code"] == "654321"
  313. def test_send_change_email_invalid_phase(
  314. self,
  315. email_service: EmailI18nService,
  316. ):
  317. """Test sending change email with invalid phase raises error"""
  318. with pytest.raises(ValueError, match="Invalid phase: invalid_phase"):
  319. email_service.send_change_email(
  320. language_code="en-US",
  321. to="test@example.com",
  322. code="123456",
  323. phase="invalid_phase",
  324. )
  325. def test_send_raw_email_single_recipient(
  326. self,
  327. email_service: EmailI18nService,
  328. mock_sender: MockEmailSender,
  329. ):
  330. """Test sending raw email to single recipient"""
  331. email_service.send_raw_email(
  332. to="test@example.com",
  333. subject="Test Subject",
  334. html_content="<html>Test Content</html>",
  335. )
  336. assert len(mock_sender.sent_emails) == 1
  337. sent_email = mock_sender.sent_emails[0]
  338. assert sent_email["to"] == "test@example.com"
  339. assert sent_email["subject"] == "Test Subject"
  340. assert sent_email["html_content"] == "<html>Test Content</html>"
  341. def test_send_raw_email_multiple_recipients(
  342. self,
  343. email_service: EmailI18nService,
  344. mock_sender: MockEmailSender,
  345. ):
  346. """Test sending raw email to multiple recipients"""
  347. recipients = ["user1@example.com", "user2@example.com", "user3@example.com"]
  348. email_service.send_raw_email(
  349. to=recipients,
  350. subject="Test Subject",
  351. html_content="<html>Test Content</html>",
  352. )
  353. # Should send individual emails to each recipient
  354. assert len(mock_sender.sent_emails) == 3
  355. for i, recipient in enumerate(recipients):
  356. sent_email = mock_sender.sent_emails[i]
  357. assert sent_email["to"] == recipient
  358. assert sent_email["subject"] == "Test Subject"
  359. assert sent_email["html_content"] == "<html>Test Content</html>"
  360. def test_get_template_missing_email_type(
  361. self,
  362. email_config: EmailI18nConfig,
  363. ):
  364. """Test getting template for missing email type raises error"""
  365. with pytest.raises(ValueError, match="No templates configured for email type"):
  366. email_config.get_template(EmailType.EMAIL_CODE_LOGIN, EmailLanguage.EN_US)
  367. def test_get_template_missing_language_and_english(
  368. self,
  369. email_config: EmailI18nConfig,
  370. ):
  371. """Test error when neither requested language nor English fallback exists"""
  372. # Add template without English fallback
  373. email_config.templates[EmailType.EMAIL_CODE_LOGIN] = {
  374. EmailLanguage.ZH_HANS: EmailTemplate(
  375. subject="Test",
  376. template_path="test.html",
  377. branded_template_path="branded/test.html",
  378. ),
  379. }
  380. with pytest.raises(ValueError, match="No template found for"):
  381. # Request a language that doesn't exist and no English fallback
  382. email_config.get_template(EmailType.EMAIL_CODE_LOGIN, EmailLanguage.EN_US)
  383. def test_subject_templating_with_variables(
  384. self,
  385. email_config: EmailI18nConfig,
  386. mock_renderer: MockEmailRenderer,
  387. mock_sender: MockEmailSender,
  388. mock_branding_service: MockBrandingService,
  389. ):
  390. """Test subject templating with custom variables"""
  391. # Add template with variable in subject
  392. email_config.templates[EmailType.OWNER_TRANSFER_NEW_NOTIFY] = {
  393. EmailLanguage.EN_US: EmailTemplate(
  394. subject="You are now the owner of {WorkspaceName}",
  395. template_path="owner_transfer_en.html",
  396. branded_template_path="branded/owner_transfer_en.html",
  397. ),
  398. }
  399. email_service = EmailI18nService(
  400. config=email_config,
  401. renderer=mock_renderer,
  402. branding_service=mock_branding_service,
  403. sender=mock_sender,
  404. )
  405. email_service.send_email(
  406. email_type=EmailType.OWNER_TRANSFER_NEW_NOTIFY,
  407. language_code="en-US",
  408. to="test@example.com",
  409. template_context={"WorkspaceName": "My Workspace"},
  410. )
  411. # Verify subject was templated correctly
  412. assert len(mock_sender.sent_emails) == 1
  413. sent_email = mock_sender.sent_emails[0]
  414. assert sent_email["subject"] == "You are now the owner of My Workspace"
  415. def test_email_language_from_language_code(self):
  416. """Test EmailLanguage.from_language_code method"""
  417. assert EmailLanguage.from_language_code("zh-Hans") == EmailLanguage.ZH_HANS
  418. assert EmailLanguage.from_language_code("en-US") == EmailLanguage.EN_US
  419. assert EmailLanguage.from_language_code("fr-FR") == EmailLanguage.EN_US # Fallback
  420. assert EmailLanguage.from_language_code("unknown") == EmailLanguage.EN_US # Fallback
  421. class TestEmailI18nIntegration:
  422. """Integration tests for email i18n components"""
  423. def test_create_default_email_config(self):
  424. """Test creating default email configuration"""
  425. config = create_default_email_config()
  426. # Verify key email types have at least English template
  427. expected_types = [
  428. EmailType.RESET_PASSWORD,
  429. EmailType.INVITE_MEMBER,
  430. EmailType.EMAIL_CODE_LOGIN,
  431. EmailType.CHANGE_EMAIL_OLD,
  432. EmailType.CHANGE_EMAIL_NEW,
  433. EmailType.OWNER_TRANSFER_CONFIRM,
  434. EmailType.OWNER_TRANSFER_OLD_NOTIFY,
  435. EmailType.OWNER_TRANSFER_NEW_NOTIFY,
  436. EmailType.ACCOUNT_DELETION_SUCCESS,
  437. EmailType.ACCOUNT_DELETION_VERIFICATION,
  438. EmailType.QUEUE_MONITOR_ALERT,
  439. EmailType.DOCUMENT_CLEAN_NOTIFY,
  440. ]
  441. for email_type in expected_types:
  442. assert email_type in config.templates
  443. assert EmailLanguage.EN_US in config.templates[email_type]
  444. # Verify some have Chinese translations
  445. assert EmailLanguage.ZH_HANS in config.templates[EmailType.RESET_PASSWORD]
  446. assert EmailLanguage.ZH_HANS in config.templates[EmailType.INVITE_MEMBER]
  447. def test_get_email_i18n_service(self):
  448. """Test getting global email i18n service instance"""
  449. service1 = get_email_i18n_service()
  450. service2 = get_email_i18n_service()
  451. # Should return the same instance
  452. assert service1 is service2
  453. def test_flask_email_renderer(self):
  454. """Test FlaskEmailRenderer implementation"""
  455. renderer = FlaskEmailRenderer()
  456. # Should raise TemplateNotFound when template doesn't exist
  457. from jinja2.exceptions import TemplateNotFound
  458. with pytest.raises(TemplateNotFound):
  459. renderer.render_template("test.html", foo="bar")
  460. def test_flask_mail_sender_not_initialized(self):
  461. """Test FlaskMailSender when mail is not initialized"""
  462. sender = FlaskMailSender()
  463. # Mock mail.is_inited() to return False
  464. import libs.email_i18n
  465. original_mail = libs.email_i18n.mail
  466. mock_mail = MagicMock()
  467. mock_mail.is_inited.return_value = False
  468. libs.email_i18n.mail = mock_mail
  469. try:
  470. # Should not send email when mail is not initialized
  471. sender.send_email("test@example.com", "Subject", "<html>Content</html>")
  472. mock_mail.send.assert_not_called()
  473. finally:
  474. # Restore original mail
  475. libs.email_i18n.mail = original_mail
  476. def test_flask_mail_sender_initialized(self):
  477. """Test FlaskMailSender when mail is initialized"""
  478. sender = FlaskMailSender()
  479. # Mock mail.is_inited() to return True
  480. import libs.email_i18n
  481. original_mail = libs.email_i18n.mail
  482. mock_mail = MagicMock()
  483. mock_mail.is_inited.return_value = True
  484. libs.email_i18n.mail = mock_mail
  485. try:
  486. # Should send email when mail is initialized
  487. sender.send_email("test@example.com", "Subject", "<html>Content</html>")
  488. mock_mail.send.assert_called_once_with(
  489. to="test@example.com",
  490. subject="Subject",
  491. html="<html>Content</html>",
  492. )
  493. finally:
  494. # Restore original mail
  495. libs.email_i18n.mail = original_mail