karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +502 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +109 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +356 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +283 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/utils/test_data.py +27 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +535 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.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 "<script>" 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 "<script>" 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()
|