karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,492 @@
1
+ """
2
+ Unit tests for email service.
3
+
4
+ Tests the new job completion and action reminder email methods,
5
+ as well as CC support.
6
+ """
7
+ import pytest
8
+ from unittest.mock import Mock, patch, MagicMock
9
+
10
+ from backend.services.email_service import (
11
+ EmailService,
12
+ EmailProvider,
13
+ ConsoleEmailProvider,
14
+ SendGridEmailProvider,
15
+ get_email_service,
16
+ )
17
+
18
+
19
+ class TestConsoleEmailProvider:
20
+ """Tests for console email provider."""
21
+
22
+ def test_send_email_basic(self):
23
+ """Test basic email logging."""
24
+ provider = ConsoleEmailProvider()
25
+
26
+ result = provider.send_email(
27
+ to_email="user@example.com",
28
+ subject="Test Subject",
29
+ html_content="<p>Test content</p>",
30
+ )
31
+
32
+ assert result is True
33
+
34
+ def test_send_email_with_text_content(self):
35
+ """Test email with text content."""
36
+ provider = ConsoleEmailProvider()
37
+
38
+ result = provider.send_email(
39
+ to_email="user@example.com",
40
+ subject="Test Subject",
41
+ html_content="<p>Test</p>",
42
+ text_content="Test plain text",
43
+ )
44
+
45
+ assert result is True
46
+
47
+ def test_send_email_with_cc(self):
48
+ """Test email with CC recipients."""
49
+ provider = ConsoleEmailProvider()
50
+
51
+ result = provider.send_email(
52
+ to_email="user@example.com",
53
+ subject="Test Subject",
54
+ html_content="<p>Test</p>",
55
+ cc_emails=["cc1@example.com", "cc2@example.com"],
56
+ )
57
+
58
+ assert result is True
59
+
60
+
61
+ class TestSendGridEmailProvider:
62
+ """Tests for SendGrid email provider."""
63
+
64
+ def test_send_email_success(self):
65
+ """Test successful email sending via SendGrid."""
66
+ provider = SendGridEmailProvider(
67
+ api_key="test-api-key",
68
+ from_email="from@example.com",
69
+ from_name="Test Sender",
70
+ )
71
+
72
+ mock_response = Mock()
73
+ mock_response.status_code = 202
74
+
75
+ # Patch at the sendgrid module level since it's imported inline
76
+ with patch('sendgrid.SendGridAPIClient') as mock_sg:
77
+ mock_client = Mock()
78
+ mock_client.send.return_value = mock_response
79
+ mock_sg.return_value = mock_client
80
+
81
+ result = provider.send_email(
82
+ to_email="user@example.com",
83
+ subject="Test Subject",
84
+ html_content="<p>Test</p>",
85
+ )
86
+
87
+ assert result is True
88
+ mock_client.send.assert_called_once()
89
+
90
+ def test_send_email_with_cc(self):
91
+ """Test email sending with CC via SendGrid."""
92
+ provider = SendGridEmailProvider(
93
+ api_key="test-api-key",
94
+ from_email="from@example.com",
95
+ )
96
+
97
+ mock_response = Mock()
98
+ mock_response.status_code = 202
99
+
100
+ with patch('sendgrid.SendGridAPIClient') as mock_sg:
101
+ mock_client = Mock()
102
+ mock_client.send.return_value = mock_response
103
+ mock_sg.return_value = mock_client
104
+
105
+ result = provider.send_email(
106
+ to_email="user@example.com",
107
+ subject="Test Subject",
108
+ html_content="<p>Test</p>",
109
+ cc_emails=["cc@example.com"],
110
+ )
111
+
112
+ assert result is True
113
+
114
+ def test_send_email_failure_status(self):
115
+ """Test email sending failure due to bad status."""
116
+ provider = SendGridEmailProvider(
117
+ api_key="test-api-key",
118
+ from_email="from@example.com",
119
+ )
120
+
121
+ mock_response = Mock()
122
+ mock_response.status_code = 400
123
+
124
+ with patch('sendgrid.SendGridAPIClient') as mock_sg:
125
+ mock_client = Mock()
126
+ mock_client.send.return_value = mock_response
127
+ mock_sg.return_value = mock_client
128
+
129
+ result = provider.send_email(
130
+ to_email="user@example.com",
131
+ subject="Test Subject",
132
+ html_content="<p>Test</p>",
133
+ )
134
+
135
+ assert result is False
136
+
137
+ def test_send_email_exception(self):
138
+ """Test email sending exception handling."""
139
+ provider = SendGridEmailProvider(
140
+ api_key="test-api-key",
141
+ from_email="from@example.com",
142
+ )
143
+
144
+ with patch('sendgrid.SendGridAPIClient') as mock_sg:
145
+ mock_sg.return_value.send.side_effect = Exception("API error")
146
+
147
+ result = provider.send_email(
148
+ to_email="user@example.com",
149
+ subject="Test Subject",
150
+ html_content="<p>Test</p>",
151
+ )
152
+
153
+ assert result is False
154
+
155
+
156
+ class TestEmailServiceJobCompletion:
157
+ """Tests for job completion email method."""
158
+
159
+ def test_send_job_completion_basic(self):
160
+ """Test basic job completion email."""
161
+ service = EmailService()
162
+ service.provider = Mock()
163
+ service.provider.send_email.return_value = True
164
+
165
+ result = service.send_job_completion(
166
+ to_email="user@example.com",
167
+ message_content="Your video is ready!",
168
+ )
169
+
170
+ assert result is True
171
+ service.provider.send_email.assert_called_once()
172
+
173
+ def test_send_job_completion_with_song_info(self):
174
+ """Test job completion email includes song in subject."""
175
+ service = EmailService()
176
+ service.provider = Mock()
177
+ service.provider.send_email.return_value = True
178
+
179
+ service.send_job_completion(
180
+ to_email="user@example.com",
181
+ message_content="Your video is ready!",
182
+ artist="Test Artist",
183
+ title="Test Song",
184
+ )
185
+
186
+ call_args = service.provider.send_email.call_args
187
+ subject = call_args.kwargs.get('subject') or call_args[0][1]
188
+ assert "Test Artist" in subject
189
+ assert "Test Song" in subject
190
+
191
+ def test_send_job_completion_with_brand_code(self):
192
+ """Test job completion email includes brand code in subject."""
193
+ service = EmailService()
194
+ service.provider = Mock()
195
+ service.provider.send_email.return_value = True
196
+
197
+ service.send_job_completion(
198
+ to_email="user@example.com",
199
+ message_content="Your video is ready!",
200
+ artist="Seether",
201
+ title="Tonight",
202
+ brand_code="NOMAD-1178",
203
+ )
204
+
205
+ call_args = service.provider.send_email.call_args
206
+ subject = call_args.kwargs.get('subject') or call_args[0][1]
207
+ # Subject format: "NOMAD-1178: Seether - Tonight (Your karaoke video is ready!)"
208
+ assert subject == "NOMAD-1178: Seether - Tonight (Your karaoke video is ready!)"
209
+
210
+ def test_send_job_completion_default_subject(self):
211
+ """Test job completion email default subject without song info."""
212
+ service = EmailService()
213
+ service.provider = Mock()
214
+ service.provider.send_email.return_value = True
215
+
216
+ service.send_job_completion(
217
+ to_email="user@example.com",
218
+ message_content="Your video is ready!",
219
+ )
220
+
221
+ call_args = service.provider.send_email.call_args
222
+ subject = call_args.kwargs.get('subject') or call_args[0][1]
223
+ assert "karaoke video is ready" in subject.lower()
224
+
225
+ def test_send_job_completion_with_cc(self):
226
+ """Test job completion email with CC to admin."""
227
+ service = EmailService()
228
+ service.provider = Mock()
229
+ service.provider.send_email.return_value = True
230
+
231
+ service.send_job_completion(
232
+ to_email="user@example.com",
233
+ message_content="Your video is ready!",
234
+ cc_admin=True,
235
+ )
236
+
237
+ call_kwargs = service.provider.send_email.call_args.kwargs
238
+ assert "gen@nomadkaraoke.com" in call_kwargs.get('cc_emails', [])
239
+
240
+ def test_send_job_completion_without_cc(self):
241
+ """Test job completion email without CC."""
242
+ service = EmailService()
243
+ service.provider = Mock()
244
+ service.provider.send_email.return_value = True
245
+
246
+ service.send_job_completion(
247
+ to_email="user@example.com",
248
+ message_content="Your video is ready!",
249
+ cc_admin=False,
250
+ )
251
+
252
+ call_kwargs = service.provider.send_email.call_args.kwargs
253
+ assert call_kwargs.get('cc_emails') is None
254
+
255
+ def test_send_job_completion_escapes_html(self):
256
+ """Test that message content is HTML-escaped."""
257
+ service = EmailService()
258
+ service.provider = Mock()
259
+ service.provider.send_email.return_value = True
260
+
261
+ service.send_job_completion(
262
+ to_email="user@example.com",
263
+ message_content="Test <script>alert('xss')</script>",
264
+ )
265
+
266
+ call_kwargs = service.provider.send_email.call_args.kwargs
267
+ html_content = call_kwargs.get('html_content')
268
+ assert "<script>" not in html_content
269
+ assert "&lt;script&gt;" in html_content
270
+
271
+ def test_send_job_completion_includes_plain_text(self):
272
+ """Test that plain text content is included."""
273
+ service = EmailService()
274
+ service.provider = Mock()
275
+ service.provider.send_email.return_value = True
276
+
277
+ message = "Your video is ready!"
278
+ service.send_job_completion(
279
+ to_email="user@example.com",
280
+ message_content=message,
281
+ )
282
+
283
+ call_kwargs = service.provider.send_email.call_args.kwargs
284
+ assert call_kwargs.get('text_content') == message
285
+
286
+
287
+ class TestEmailServiceActionReminder:
288
+ """Tests for action reminder email method."""
289
+
290
+ def test_send_action_reminder_lyrics(self):
291
+ """Test lyrics action reminder email."""
292
+ service = EmailService()
293
+ service.provider = Mock()
294
+ service.provider.send_email.return_value = True
295
+
296
+ result = service.send_action_reminder(
297
+ to_email="user@example.com",
298
+ message_content="Please review your lyrics",
299
+ action_type="lyrics",
300
+ )
301
+
302
+ assert result is True
303
+ call_kwargs = service.provider.send_email.call_args.kwargs
304
+ subject = call_kwargs.get('subject')
305
+ assert "lyrics" in subject.lower()
306
+
307
+ def test_send_action_reminder_instrumental(self):
308
+ """Test instrumental action reminder email."""
309
+ service = EmailService()
310
+ service.provider = Mock()
311
+ service.provider.send_email.return_value = True
312
+
313
+ result = service.send_action_reminder(
314
+ to_email="user@example.com",
315
+ message_content="Please select instrumental",
316
+ action_type="instrumental",
317
+ )
318
+
319
+ assert result is True
320
+ call_kwargs = service.provider.send_email.call_args.kwargs
321
+ subject = call_kwargs.get('subject')
322
+ assert "instrumental" in subject.lower()
323
+
324
+ def test_send_action_reminder_with_song_info(self):
325
+ """Test action reminder includes song in subject."""
326
+ service = EmailService()
327
+ service.provider = Mock()
328
+ service.provider.send_email.return_value = True
329
+
330
+ service.send_action_reminder(
331
+ to_email="user@example.com",
332
+ message_content="Please review",
333
+ action_type="lyrics",
334
+ artist="Test Artist",
335
+ title="Test Song",
336
+ )
337
+
338
+ call_kwargs = service.provider.send_email.call_args.kwargs
339
+ subject = call_kwargs.get('subject')
340
+ assert "Test Artist" in subject
341
+ assert "Test Song" in subject
342
+
343
+ def test_send_action_reminder_unknown_type(self):
344
+ """Test action reminder with unknown type still sends."""
345
+ service = EmailService()
346
+ service.provider = Mock()
347
+ service.provider.send_email.return_value = True
348
+
349
+ result = service.send_action_reminder(
350
+ to_email="user@example.com",
351
+ message_content="Please take action",
352
+ action_type="unknown",
353
+ )
354
+
355
+ assert result is True
356
+ call_kwargs = service.provider.send_email.call_args.kwargs
357
+ subject = call_kwargs.get('subject')
358
+ assert "Action needed" in subject
359
+
360
+ def test_send_action_reminder_escapes_html(self):
361
+ """Test that message content is HTML-escaped."""
362
+ service = EmailService()
363
+ service.provider = Mock()
364
+ service.provider.send_email.return_value = True
365
+
366
+ service.send_action_reminder(
367
+ to_email="user@example.com",
368
+ message_content="Test <script>alert('xss')</script>",
369
+ action_type="lyrics",
370
+ )
371
+
372
+ call_kwargs = service.provider.send_email.call_args.kwargs
373
+ html_content = call_kwargs.get('html_content')
374
+ assert "<script>" not in html_content
375
+ assert "&lt;script&gt;" in html_content
376
+
377
+ def test_send_action_reminder_no_cc(self):
378
+ """Test that action reminders don't have CC."""
379
+ service = EmailService()
380
+ service.provider = Mock()
381
+ service.provider.send_email.return_value = True
382
+
383
+ service.send_action_reminder(
384
+ to_email="user@example.com",
385
+ message_content="Please review",
386
+ action_type="lyrics",
387
+ )
388
+
389
+ call_kwargs = service.provider.send_email.call_args.kwargs
390
+ # Action reminders should not have CC
391
+ assert call_kwargs.get('cc_emails') is None
392
+
393
+
394
+ class TestEmailServiceConfiguration:
395
+ """Tests for email service configuration."""
396
+
397
+ def test_uses_sendgrid_when_configured(self):
398
+ """Test that SendGrid is used when API key is set."""
399
+ with patch.dict('os.environ', {'SENDGRID_API_KEY': 'test-key'}):
400
+ service = EmailService()
401
+ assert isinstance(service.provider, SendGridEmailProvider)
402
+
403
+ def test_uses_console_when_not_configured(self):
404
+ """Test that console is used when no API key."""
405
+ with patch.dict('os.environ', {}, clear=True):
406
+ # Remove SENDGRID_API_KEY if it exists
407
+ import os
408
+ original = os.environ.pop('SENDGRID_API_KEY', None)
409
+ try:
410
+ service = EmailService()
411
+ assert isinstance(service.provider, ConsoleEmailProvider)
412
+ finally:
413
+ if original:
414
+ os.environ['SENDGRID_API_KEY'] = original
415
+
416
+ def test_is_configured_true_for_sendgrid(self):
417
+ """Test is_configured returns True for SendGrid."""
418
+ service = EmailService()
419
+ service.provider = SendGridEmailProvider("key", "from@example.com")
420
+ assert service.is_configured() is True
421
+
422
+ def test_is_configured_false_for_console(self):
423
+ """Test is_configured returns False for console."""
424
+ service = EmailService()
425
+ service.provider = ConsoleEmailProvider()
426
+ assert service.is_configured() is False
427
+
428
+
429
+ class TestGlobalInstance:
430
+ """Tests for global instance management."""
431
+
432
+ def test_get_email_service_returns_same_instance(self):
433
+ """Test that get_email_service returns singleton."""
434
+ # Reset global
435
+ import backend.services.email_service as es
436
+ es._email_service = None
437
+
438
+ service1 = get_email_service()
439
+ service2 = get_email_service()
440
+
441
+ assert service1 is service2
442
+
443
+
444
+ class TestCCFunctionality:
445
+ """Integration tests for CC functionality across providers."""
446
+
447
+ def test_console_provider_logs_cc(self, caplog):
448
+ """Test that console provider logs CC recipients."""
449
+ import logging
450
+ caplog.set_level(logging.INFO)
451
+
452
+ provider = ConsoleEmailProvider()
453
+ provider.send_email(
454
+ to_email="user@example.com",
455
+ subject="Test",
456
+ html_content="<p>Test</p>",
457
+ cc_emails=["cc1@example.com", "cc2@example.com"],
458
+ )
459
+
460
+ # Check that CC was logged
461
+ assert "cc1@example.com" in caplog.text or "CC:" in caplog.text
462
+
463
+ def test_sendgrid_provider_adds_cc_recipients(self):
464
+ """Test that SendGrid provider adds CC recipients to message."""
465
+ provider = SendGridEmailProvider(
466
+ api_key="test-api-key",
467
+ from_email="from@example.com",
468
+ )
469
+
470
+ mock_response = Mock()
471
+ mock_response.status_code = 202
472
+
473
+ # Patch at sendgrid module level since imports are inline
474
+ with patch('sendgrid.SendGridAPIClient') as mock_sg:
475
+ mock_client = Mock()
476
+ mock_client.send.return_value = mock_response
477
+ mock_sg.return_value = mock_client
478
+
479
+ # We need to capture the Mail object
480
+ with patch('sendgrid.helpers.mail.Mail') as mock_mail_class:
481
+ mock_mail = MagicMock()
482
+ mock_mail_class.return_value = mock_mail
483
+
484
+ provider.send_email(
485
+ to_email="user@example.com",
486
+ subject="Test",
487
+ html_content="<p>Test</p>",
488
+ cc_emails=["cc@example.com"],
489
+ )
490
+
491
+ # Verify add_cc was called
492
+ mock_mail.add_cc.assert_called()