karaoke-gen 0.86.7__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 (188) 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/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,443 @@
1
+ """
2
+ Unit tests for job notification service.
3
+ """
4
+ import pytest
5
+ from unittest.mock import Mock, patch, AsyncMock
6
+ import urllib.parse
7
+
8
+ from backend.services.job_notification_service import (
9
+ JobNotificationService,
10
+ get_job_notification_service,
11
+ ENABLE_AUTO_EMAILS,
12
+ FEEDBACK_FORM_URL,
13
+ )
14
+
15
+
16
+ class TestURLBuilding:
17
+ """Tests for URL building methods."""
18
+
19
+ def test_build_review_url_basic(self):
20
+ """Test basic review URL building."""
21
+ service = JobNotificationService()
22
+ service.frontend_url = "https://gen.nomadkaraoke.com"
23
+ service.backend_url = "https://api.nomadkaraoke.com"
24
+
25
+ url = service._build_review_url("job-123")
26
+
27
+ assert "gen.nomadkaraoke.com/lyrics/" in url
28
+ assert "baseApiUrl=" in url
29
+ # The API URL should be URL-encoded
30
+ assert urllib.parse.quote("https://api.nomadkaraoke.com/api/review/job-123", safe='') in url
31
+
32
+ def test_build_review_url_with_audio_hash(self):
33
+ """Test review URL with audio hash parameter."""
34
+ service = JobNotificationService()
35
+ service.frontend_url = "https://gen.nomadkaraoke.com"
36
+ service.backend_url = "https://api.nomadkaraoke.com"
37
+
38
+ url = service._build_review_url("job-123", audio_hash="abc123")
39
+
40
+ assert "audioHash=abc123" in url
41
+
42
+ def test_build_review_url_with_review_token(self):
43
+ """Test review URL with review token parameter."""
44
+ service = JobNotificationService()
45
+ service.frontend_url = "https://gen.nomadkaraoke.com"
46
+ service.backend_url = "https://api.nomadkaraoke.com"
47
+
48
+ url = service._build_review_url("job-123", review_token="token456")
49
+
50
+ assert "reviewToken=token456" in url
51
+
52
+ def test_build_review_url_with_all_params(self):
53
+ """Test review URL with all parameters."""
54
+ service = JobNotificationService()
55
+ service.frontend_url = "https://gen.nomadkaraoke.com"
56
+ service.backend_url = "https://api.nomadkaraoke.com"
57
+
58
+ url = service._build_review_url(
59
+ "job-123",
60
+ audio_hash="hash789",
61
+ review_token="token456"
62
+ )
63
+
64
+ assert "baseApiUrl=" in url
65
+ assert "audioHash=hash789" in url
66
+ assert "reviewToken=token456" in url
67
+
68
+ def test_build_review_url_encodes_special_chars(self):
69
+ """Test that special characters in job ID are encoded."""
70
+ service = JobNotificationService()
71
+ service.frontend_url = "https://gen.nomadkaraoke.com"
72
+ service.backend_url = "https://api.nomadkaraoke.com"
73
+
74
+ url = service._build_review_url("job/with/slashes")
75
+
76
+ # The baseApiUrl parameter should have encoded slashes
77
+ assert "%2F" in url
78
+
79
+ def test_build_instrumental_url_basic(self):
80
+ """Test basic instrumental URL building."""
81
+ service = JobNotificationService()
82
+ service.frontend_url = "https://gen.nomadkaraoke.com"
83
+ service.backend_url = "https://api.nomadkaraoke.com"
84
+
85
+ url = service._build_instrumental_url("job-123")
86
+
87
+ assert "gen.nomadkaraoke.com/instrumental/" in url
88
+ assert "baseApiUrl=" in url
89
+
90
+ def test_build_instrumental_url_with_token(self):
91
+ """Test instrumental URL with token parameter."""
92
+ service = JobNotificationService()
93
+ service.frontend_url = "https://gen.nomadkaraoke.com"
94
+ service.backend_url = "https://api.nomadkaraoke.com"
95
+
96
+ url = service._build_instrumental_url("job-123", instrumental_token="inst-token")
97
+
98
+ assert "instrumentalToken=inst-token" in url
99
+
100
+
101
+ class TestCompletionEmail:
102
+ """Tests for job completion email sending."""
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_send_completion_email_success(self):
106
+ """Test successful completion email sending."""
107
+ service = JobNotificationService()
108
+ service.email_service = Mock()
109
+ service.email_service.send_job_completion.return_value = True
110
+ service.template_service = Mock()
111
+ service.template_service.render_job_completion.return_value = "Test message"
112
+
113
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
114
+ result = await service.send_job_completion_email(
115
+ job_id="job-123",
116
+ user_email="user@example.com",
117
+ user_name="Test User",
118
+ artist="Test Artist",
119
+ title="Test Song",
120
+ youtube_url="https://youtube.com/watch?v=123",
121
+ dropbox_url="https://dropbox.com/folder/abc",
122
+ )
123
+
124
+ assert result is True
125
+ service.template_service.render_job_completion.assert_called_once()
126
+ service.email_service.send_job_completion.assert_called_once()
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_send_completion_email_disabled(self):
130
+ """Test that completion email is skipped when auto emails are disabled."""
131
+ service = JobNotificationService()
132
+ service.email_service = Mock()
133
+ service.template_service = Mock()
134
+
135
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', False):
136
+ result = await service.send_job_completion_email(
137
+ job_id="job-123",
138
+ user_email="user@example.com",
139
+ )
140
+
141
+ assert result is False
142
+ service.email_service.send_job_completion.assert_not_called()
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_send_completion_email_no_email(self):
146
+ """Test that completion email is skipped when no user email."""
147
+ service = JobNotificationService()
148
+ service.email_service = Mock()
149
+ service.template_service = Mock()
150
+
151
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
152
+ result = await service.send_job_completion_email(
153
+ job_id="job-123",
154
+ user_email=None,
155
+ )
156
+
157
+ assert result is False
158
+ service.email_service.send_job_completion.assert_not_called()
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_send_completion_email_empty_email(self):
162
+ """Test that completion email is skipped when user email is empty."""
163
+ service = JobNotificationService()
164
+ service.email_service = Mock()
165
+ service.template_service = Mock()
166
+
167
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
168
+ result = await service.send_job_completion_email(
169
+ job_id="job-123",
170
+ user_email="",
171
+ )
172
+
173
+ assert result is False
174
+ service.email_service.send_job_completion.assert_not_called()
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_send_completion_email_send_failure(self):
178
+ """Test handling of email send failure."""
179
+ service = JobNotificationService()
180
+ service.email_service = Mock()
181
+ service.email_service.send_job_completion.return_value = False
182
+ service.template_service = Mock()
183
+ service.template_service.render_job_completion.return_value = "Test message"
184
+
185
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
186
+ result = await service.send_job_completion_email(
187
+ job_id="job-123",
188
+ user_email="user@example.com",
189
+ )
190
+
191
+ assert result is False
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_send_completion_email_exception(self):
195
+ """Test handling of exceptions during email sending."""
196
+ service = JobNotificationService()
197
+ service.email_service = Mock()
198
+ service.email_service.send_job_completion.side_effect = Exception("Send error")
199
+ service.template_service = Mock()
200
+ service.template_service.render_job_completion.return_value = "Test message"
201
+
202
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
203
+ result = await service.send_job_completion_email(
204
+ job_id="job-123",
205
+ user_email="user@example.com",
206
+ )
207
+
208
+ assert result is False
209
+
210
+ @pytest.mark.asyncio
211
+ async def test_send_completion_email_passes_cc_admin(self):
212
+ """Test that completion email is sent with CC to admin."""
213
+ service = JobNotificationService()
214
+ service.email_service = Mock()
215
+ service.email_service.send_job_completion.return_value = True
216
+ service.template_service = Mock()
217
+ service.template_service.render_job_completion.return_value = "Test message"
218
+
219
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
220
+ await service.send_job_completion_email(
221
+ job_id="job-123",
222
+ user_email="user@example.com",
223
+ )
224
+
225
+ # Verify cc_admin=True was passed
226
+ call_kwargs = service.email_service.send_job_completion.call_args.kwargs
227
+ assert call_kwargs.get('cc_admin') is True
228
+
229
+
230
+ class TestActionReminderEmail:
231
+ """Tests for action reminder email sending."""
232
+
233
+ @pytest.mark.asyncio
234
+ async def test_send_lyrics_reminder_success(self):
235
+ """Test successful lyrics reminder email sending."""
236
+ service = JobNotificationService()
237
+ service.email_service = Mock()
238
+ service.email_service.send_action_reminder.return_value = True
239
+ service.template_service = Mock()
240
+ service.template_service.render_action_needed_lyrics.return_value = "Review your lyrics"
241
+
242
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
243
+ result = await service.send_action_reminder_email(
244
+ job_id="job-123",
245
+ user_email="user@example.com",
246
+ action_type="lyrics",
247
+ artist="Test Artist",
248
+ title="Test Song",
249
+ )
250
+
251
+ assert result is True
252
+ service.template_service.render_action_needed_lyrics.assert_called_once()
253
+ service.email_service.send_action_reminder.assert_called_once()
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_send_instrumental_reminder_success(self):
257
+ """Test successful instrumental reminder email sending."""
258
+ service = JobNotificationService()
259
+ service.email_service = Mock()
260
+ service.email_service.send_action_reminder.return_value = True
261
+ service.template_service = Mock()
262
+ service.template_service.render_action_needed_instrumental.return_value = "Select instrumental"
263
+
264
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
265
+ result = await service.send_action_reminder_email(
266
+ job_id="job-123",
267
+ user_email="user@example.com",
268
+ action_type="instrumental",
269
+ )
270
+
271
+ assert result is True
272
+ service.template_service.render_action_needed_instrumental.assert_called_once()
273
+ service.email_service.send_action_reminder.assert_called_once()
274
+
275
+ @pytest.mark.asyncio
276
+ async def test_send_reminder_disabled(self):
277
+ """Test that reminder email is skipped when auto emails are disabled."""
278
+ service = JobNotificationService()
279
+ service.email_service = Mock()
280
+
281
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', False):
282
+ result = await service.send_action_reminder_email(
283
+ job_id="job-123",
284
+ user_email="user@example.com",
285
+ action_type="lyrics",
286
+ )
287
+
288
+ assert result is False
289
+ service.email_service.send_action_reminder.assert_not_called()
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_send_reminder_no_email(self):
293
+ """Test that reminder email is skipped when no user email."""
294
+ service = JobNotificationService()
295
+ service.email_service = Mock()
296
+
297
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
298
+ result = await service.send_action_reminder_email(
299
+ job_id="job-123",
300
+ user_email=None,
301
+ action_type="lyrics",
302
+ )
303
+
304
+ assert result is False
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_send_reminder_unknown_action_type(self):
308
+ """Test handling of unknown action type."""
309
+ service = JobNotificationService()
310
+ service.email_service = Mock()
311
+ service.template_service = Mock()
312
+
313
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
314
+ result = await service.send_action_reminder_email(
315
+ job_id="job-123",
316
+ user_email="user@example.com",
317
+ action_type="unknown",
318
+ )
319
+
320
+ assert result is False
321
+ service.email_service.send_action_reminder.assert_not_called()
322
+
323
+ @pytest.mark.asyncio
324
+ async def test_send_lyrics_reminder_includes_review_url(self):
325
+ """Test that lyrics reminder includes correct review URL."""
326
+ service = JobNotificationService()
327
+ service.frontend_url = "https://gen.nomadkaraoke.com"
328
+ service.backend_url = "https://api.nomadkaraoke.com"
329
+ service.email_service = Mock()
330
+ service.email_service.send_action_reminder.return_value = True
331
+ service.template_service = Mock()
332
+ service.template_service.render_action_needed_lyrics.return_value = "Review"
333
+
334
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
335
+ await service.send_action_reminder_email(
336
+ job_id="job-123",
337
+ user_email="user@example.com",
338
+ action_type="lyrics",
339
+ audio_hash="hash123",
340
+ review_token="token456",
341
+ )
342
+
343
+ # Verify the review URL was passed to template
344
+ call_kwargs = service.template_service.render_action_needed_lyrics.call_args.kwargs
345
+ review_url = call_kwargs.get('review_url')
346
+ assert "audioHash=hash123" in review_url
347
+ assert "reviewToken=token456" in review_url
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_send_instrumental_reminder_includes_url(self):
351
+ """Test that instrumental reminder includes correct URL."""
352
+ service = JobNotificationService()
353
+ service.frontend_url = "https://gen.nomadkaraoke.com"
354
+ service.backend_url = "https://api.nomadkaraoke.com"
355
+ service.email_service = Mock()
356
+ service.email_service.send_action_reminder.return_value = True
357
+ service.template_service = Mock()
358
+ service.template_service.render_action_needed_instrumental.return_value = "Select"
359
+
360
+ with patch('backend.services.job_notification_service.ENABLE_AUTO_EMAILS', True):
361
+ await service.send_action_reminder_email(
362
+ job_id="job-123",
363
+ user_email="user@example.com",
364
+ action_type="instrumental",
365
+ instrumental_token="inst-token",
366
+ )
367
+
368
+ # Verify the instrumental URL was passed to template
369
+ call_kwargs = service.template_service.render_action_needed_instrumental.call_args.kwargs
370
+ instrumental_url = call_kwargs.get('instrumental_url')
371
+ assert "instrumentalToken=inst-token" in instrumental_url
372
+
373
+
374
+ class TestGetCompletionMessage:
375
+ """Tests for get_completion_message method."""
376
+
377
+ def test_get_completion_message_basic(self):
378
+ """Test basic completion message retrieval."""
379
+ service = JobNotificationService()
380
+ service.template_service = Mock()
381
+ service.template_service.render_job_completion.return_value = "Your video is ready!"
382
+
383
+ result = service.get_completion_message(
384
+ job_id="job-123",
385
+ user_name="Test User",
386
+ artist="Test Artist",
387
+ title="Test Song",
388
+ youtube_url="https://youtube.com/watch?v=123",
389
+ dropbox_url="https://dropbox.com/folder/abc",
390
+ )
391
+
392
+ assert result == "Your video is ready!"
393
+ service.template_service.render_job_completion.assert_called_once()
394
+
395
+ def test_get_completion_message_includes_feedback_url(self):
396
+ """Test that completion message includes feedback URL."""
397
+ service = JobNotificationService()
398
+ service.template_service = Mock()
399
+ service.template_service.render_job_completion.return_value = "Message"
400
+
401
+ service.get_completion_message(job_id="job-123")
402
+
403
+ # Verify feedback URL was passed
404
+ call_kwargs = service.template_service.render_job_completion.call_args.kwargs
405
+ assert 'feedback_url' in call_kwargs
406
+
407
+ def test_get_completion_message_with_defaults(self):
408
+ """Test completion message with default values."""
409
+ service = JobNotificationService()
410
+ service.template_service = Mock()
411
+ service.template_service.render_job_completion.return_value = "Message"
412
+
413
+ service.get_completion_message(job_id="job-123")
414
+
415
+ call_kwargs = service.template_service.render_job_completion.call_args.kwargs
416
+ assert call_kwargs.get('job_id') == "job-123"
417
+ # Other params should be None/default
418
+ assert call_kwargs.get('name') is None
419
+ assert call_kwargs.get('artist') is None
420
+
421
+
422
+ class TestGlobalInstance:
423
+ """Tests for global instance management."""
424
+
425
+ def test_get_job_notification_service_returns_same_instance(self):
426
+ """Test that get_job_notification_service returns singleton."""
427
+ # Reset global
428
+ import backend.services.job_notification_service as jns
429
+ jns._job_notification_service = None
430
+
431
+ service1 = get_job_notification_service()
432
+ service2 = get_job_notification_service()
433
+
434
+ assert service1 is service2
435
+
436
+ def test_service_initializes_with_dependencies(self):
437
+ """Test that service initializes with email and template services."""
438
+ service = JobNotificationService()
439
+
440
+ assert service.email_service is not None
441
+ assert service.template_service is not None
442
+ assert service.frontend_url is not None
443
+ assert service.backend_url is not None