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.
Files changed (197) 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 +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -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 +502 -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 +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,411 @@
1
+ """
2
+ Unit tests for admin email endpoints.
3
+
4
+ Tests the completion message and send email API endpoints.
5
+ """
6
+ import pytest
7
+ from unittest.mock import Mock, patch, MagicMock, AsyncMock
8
+ from fastapi.testclient import TestClient
9
+ from fastapi import FastAPI
10
+
11
+ from backend.api.routes.admin import router
12
+ from backend.api.dependencies import require_admin
13
+ from backend.models.job import Job, JobStatus
14
+
15
+
16
+ # Create a test app with the admin router
17
+ # The router already has prefix="/admin", so we add /api prefix
18
+ app = FastAPI()
19
+ app.include_router(router, prefix="/api")
20
+
21
+
22
+ def get_mock_admin():
23
+ """Override for require_admin dependency."""
24
+ return ("admin@example.com", "admin", 1)
25
+
26
+
27
+ # Override the require_admin dependency
28
+ app.dependency_overrides[require_admin] = get_mock_admin
29
+
30
+
31
+ @pytest.fixture
32
+ def client():
33
+ """Create a test client."""
34
+ return TestClient(app)
35
+
36
+
37
+ @pytest.fixture
38
+ def mock_job():
39
+ """Create a mock job."""
40
+ job = Mock(spec=Job)
41
+ job.job_id = "test-job-123"
42
+ job.user_email = "user@example.com"
43
+ job.artist = "Test Artist"
44
+ job.title = "Test Song"
45
+ job.status = JobStatus.COMPLETE
46
+ job.audio_hash = "hash123"
47
+ job.review_token = "review123"
48
+ job.instrumental_token = "inst123"
49
+ job.state_data = {
50
+ "youtube_url": "https://youtube.com/watch?v=test123",
51
+ "dropbox_link": "https://dropbox.com/folder/test",
52
+ "brand_code": "NOMAD-1234",
53
+ }
54
+ return job
55
+
56
+
57
+ class TestGetCompletionMessage:
58
+ """Tests for GET /api/admin/jobs/{job_id}/completion-message endpoint."""
59
+
60
+ def test_returns_completion_message(self, client, mock_job):
61
+ """Test successful completion message retrieval."""
62
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
63
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns:
64
+
65
+ # Setup mocks
66
+ mock_jm = Mock()
67
+ mock_jm.get_job.return_value = mock_job
68
+ mock_jm_class.return_value = mock_jm
69
+
70
+ mock_ns = Mock()
71
+ mock_ns.get_completion_message.return_value = "Your video is ready!"
72
+ mock_get_ns.return_value = mock_ns
73
+
74
+ response = client.get(
75
+ "/api/admin/jobs/test-job-123/completion-message",
76
+ headers={"Authorization": "Bearer admin-token"}
77
+ )
78
+
79
+ assert response.status_code == 200
80
+ data = response.json()
81
+ assert data["job_id"] == "test-job-123"
82
+ assert data["message"] == "Your video is ready!"
83
+ # Subject format: "NOMAD-1234: Test Artist - Test Song (Your karaoke video is ready!)"
84
+ assert data["subject"] == "NOMAD-1234: Test Artist - Test Song (Your karaoke video is ready!)"
85
+ assert data["youtube_url"] == "https://youtube.com/watch?v=test123"
86
+ assert data["dropbox_url"] == "https://dropbox.com/folder/test"
87
+
88
+ def test_returns_404_when_job_not_found(self, client):
89
+ """Test 404 when job doesn't exist."""
90
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
91
+ mock_jm = Mock()
92
+ mock_jm.get_job.return_value = None
93
+ mock_jm_class.return_value = mock_jm
94
+
95
+ response = client.get(
96
+ "/api/admin/jobs/nonexistent-job/completion-message",
97
+ headers={"Authorization": "Bearer admin-token"}
98
+ )
99
+
100
+ assert response.status_code == 404
101
+
102
+ def test_handles_none_state_data(self, client, mock_job):
103
+ """Test handling of job with None state_data."""
104
+ mock_job.state_data = None
105
+
106
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
107
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns:
108
+
109
+ mock_jm = Mock()
110
+ mock_jm.get_job.return_value = mock_job
111
+ mock_jm_class.return_value = mock_jm
112
+
113
+ mock_ns = Mock()
114
+ mock_ns.get_completion_message.return_value = "Your video is ready!"
115
+ mock_get_ns.return_value = mock_ns
116
+
117
+ response = client.get(
118
+ "/api/admin/jobs/test-job-123/completion-message",
119
+ headers={"Authorization": "Bearer admin-token"}
120
+ )
121
+
122
+ assert response.status_code == 200
123
+ data = response.json()
124
+ assert data["youtube_url"] is None
125
+ assert data["dropbox_url"] is None
126
+
127
+ def test_default_subject_without_song_info(self, client, mock_job):
128
+ """Test default subject when no artist/title."""
129
+ mock_job.artist = None
130
+ mock_job.title = None
131
+
132
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
133
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns:
134
+
135
+ mock_jm = Mock()
136
+ mock_jm.get_job.return_value = mock_job
137
+ mock_jm_class.return_value = mock_jm
138
+
139
+ mock_ns = Mock()
140
+ mock_ns.get_completion_message.return_value = "Message"
141
+ mock_get_ns.return_value = mock_ns
142
+
143
+ response = client.get(
144
+ "/api/admin/jobs/test-job-123/completion-message",
145
+ headers={"Authorization": "Bearer admin-token"}
146
+ )
147
+
148
+ assert response.status_code == 200
149
+ data = response.json()
150
+ assert data["subject"] == "Your karaoke video is ready!"
151
+
152
+
153
+ class TestSendCompletionEmail:
154
+ """Tests for POST /api/admin/jobs/{job_id}/send-completion-email endpoint."""
155
+
156
+ def test_sends_email_successfully(self, client, mock_job):
157
+ """Test successful email sending."""
158
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
159
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns, \
160
+ patch('backend.services.email_service.get_email_service') as mock_get_es:
161
+
162
+ mock_jm = Mock()
163
+ mock_jm.get_job.return_value = mock_job
164
+ mock_jm_class.return_value = mock_jm
165
+
166
+ mock_ns = Mock()
167
+ mock_ns.get_completion_message.return_value = "Your video is ready!"
168
+ mock_get_ns.return_value = mock_ns
169
+
170
+ mock_es = Mock()
171
+ mock_es.send_job_completion.return_value = True
172
+ mock_get_es.return_value = mock_es
173
+
174
+ response = client.post(
175
+ "/api/admin/jobs/test-job-123/send-completion-email",
176
+ json={"to_email": "customer@example.com", "cc_admin": True},
177
+ headers={"Authorization": "Bearer admin-token"}
178
+ )
179
+
180
+ assert response.status_code == 200
181
+ data = response.json()
182
+ assert data["success"] is True
183
+ assert data["job_id"] == "test-job-123"
184
+ assert data["to_email"] == "customer@example.com"
185
+
186
+ # Verify email was sent with correct params
187
+ mock_es.send_job_completion.assert_called_once_with(
188
+ to_email="customer@example.com",
189
+ message_content="Your video is ready!",
190
+ artist="Test Artist",
191
+ title="Test Song",
192
+ brand_code="NOMAD-1234",
193
+ cc_admin=True,
194
+ )
195
+
196
+ def test_sends_email_without_cc(self, client, mock_job):
197
+ """Test email sending without CC."""
198
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
199
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns, \
200
+ patch('backend.services.email_service.get_email_service') as mock_get_es:
201
+
202
+ mock_jm = Mock()
203
+ mock_jm.get_job.return_value = mock_job
204
+ mock_jm_class.return_value = mock_jm
205
+
206
+ mock_ns = Mock()
207
+ mock_ns.get_completion_message.return_value = "Message"
208
+ mock_get_ns.return_value = mock_ns
209
+
210
+ mock_es = Mock()
211
+ mock_es.send_job_completion.return_value = True
212
+ mock_get_es.return_value = mock_es
213
+
214
+ response = client.post(
215
+ "/api/admin/jobs/test-job-123/send-completion-email",
216
+ json={"to_email": "customer@example.com", "cc_admin": False},
217
+ headers={"Authorization": "Bearer admin-token"}
218
+ )
219
+
220
+ assert response.status_code == 200
221
+
222
+ # Verify CC was not included
223
+ call_kwargs = mock_es.send_job_completion.call_args.kwargs
224
+ assert call_kwargs["cc_admin"] is False
225
+
226
+ def test_returns_404_when_job_not_found(self, client):
227
+ """Test 404 when job doesn't exist."""
228
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
229
+ mock_jm = Mock()
230
+ mock_jm.get_job.return_value = None
231
+ mock_jm_class.return_value = mock_jm
232
+
233
+ response = client.post(
234
+ "/api/admin/jobs/nonexistent-job/send-completion-email",
235
+ json={"to_email": "customer@example.com"},
236
+ headers={"Authorization": "Bearer admin-token"}
237
+ )
238
+
239
+ assert response.status_code == 404
240
+
241
+ def test_returns_500_when_email_fails(self, client, mock_job):
242
+ """Test 500 when email sending fails."""
243
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
244
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns, \
245
+ patch('backend.services.email_service.get_email_service') as mock_get_es:
246
+
247
+ mock_jm = Mock()
248
+ mock_jm.get_job.return_value = mock_job
249
+ mock_jm_class.return_value = mock_jm
250
+
251
+ mock_ns = Mock()
252
+ mock_ns.get_completion_message.return_value = "Message"
253
+ mock_get_ns.return_value = mock_ns
254
+
255
+ mock_es = Mock()
256
+ mock_es.send_job_completion.return_value = False # Email failed
257
+ mock_get_es.return_value = mock_es
258
+
259
+ response = client.post(
260
+ "/api/admin/jobs/test-job-123/send-completion-email",
261
+ json={"to_email": "customer@example.com"},
262
+ headers={"Authorization": "Bearer admin-token"}
263
+ )
264
+
265
+ assert response.status_code == 500
266
+ assert "Failed to send email" in response.json()["detail"]
267
+
268
+ def test_handles_none_state_data(self, client, mock_job):
269
+ """Test handling of job with None state_data."""
270
+ mock_job.state_data = None
271
+
272
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
273
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns, \
274
+ patch('backend.services.email_service.get_email_service') as mock_get_es:
275
+
276
+ mock_jm = Mock()
277
+ mock_jm.get_job.return_value = mock_job
278
+ mock_jm_class.return_value = mock_jm
279
+
280
+ mock_ns = Mock()
281
+ mock_ns.get_completion_message.return_value = "Message"
282
+ mock_get_ns.return_value = mock_ns
283
+
284
+ mock_es = Mock()
285
+ mock_es.send_job_completion.return_value = True
286
+ mock_get_es.return_value = mock_es
287
+
288
+ response = client.post(
289
+ "/api/admin/jobs/test-job-123/send-completion-email",
290
+ json={"to_email": "customer@example.com"},
291
+ headers={"Authorization": "Bearer admin-token"}
292
+ )
293
+
294
+ assert response.status_code == 200
295
+
296
+ def test_accepts_any_email_string(self, client, mock_job):
297
+ """Test that any string is currently accepted as email (no validation).
298
+
299
+ Documents current behavior: FastAPI/Pydantic doesn't validate email
300
+ format by default. If validation is added later, this test should
301
+ be updated to expect 422 for invalid emails.
302
+ """
303
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
304
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns, \
305
+ patch('backend.services.email_service.get_email_service') as mock_get_es:
306
+
307
+ mock_jm = Mock()
308
+ mock_jm.get_job.return_value = mock_job
309
+ mock_jm_class.return_value = mock_jm
310
+
311
+ mock_ns = Mock()
312
+ mock_ns.get_completion_message.return_value = "Message"
313
+ mock_get_ns.return_value = mock_ns
314
+
315
+ mock_es = Mock()
316
+ mock_es.send_job_completion.return_value = True
317
+ mock_get_es.return_value = mock_es
318
+
319
+ response = client.post(
320
+ "/api/admin/jobs/test-job-123/send-completion-email",
321
+ json={"to_email": "invalid-email"}, # Not a valid email format
322
+ headers={"Authorization": "Bearer admin-token"}
323
+ )
324
+
325
+ # Currently accepts any string - endpoint succeeds
326
+ assert response.status_code == 200
327
+ # Verify the invalid email was passed through
328
+ mock_es.send_job_completion.assert_called_once()
329
+ call_kwargs = mock_es.send_job_completion.call_args.kwargs
330
+ assert call_kwargs["to_email"] == "invalid-email"
331
+
332
+
333
+ class TestIdleReminderEndpoint:
334
+ """Tests for the internal idle reminder endpoint."""
335
+
336
+ def test_sends_reminder_when_still_idle(self, mock_job):
337
+ """Test that reminder is sent when user is still idle."""
338
+ from backend.api.routes.internal import router as internal_router
339
+ from backend.api.dependencies import require_admin
340
+
341
+ # Create test app for internal router
342
+ # The router already has prefix="/internal", so we add /api prefix
343
+ test_app = FastAPI()
344
+ test_app.include_router(internal_router, prefix="/api")
345
+ test_app.dependency_overrides[require_admin] = get_mock_admin
346
+
347
+ mock_job.status = JobStatus.AWAITING_REVIEW.value # Use .value for string comparison
348
+ mock_job.state_data = {
349
+ "blocking_state_entered_at": "2024-01-01T00:00:00",
350
+ "blocking_action_type": "lyrics",
351
+ "reminder_sent": False,
352
+ }
353
+
354
+ with patch('backend.api.routes.internal.JobManager') as mock_jm_class, \
355
+ patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_ns:
356
+
357
+ mock_jm = Mock()
358
+ mock_jm.get_job.return_value = mock_job
359
+ mock_jm.firestore = Mock() # Mock firestore for update_job call
360
+ mock_jm_class.return_value = mock_jm
361
+
362
+ mock_ns = Mock()
363
+ mock_ns.send_action_reminder_email = AsyncMock(return_value=True)
364
+ mock_get_ns.return_value = mock_ns
365
+
366
+ test_client = TestClient(test_app)
367
+ response = test_client.post(
368
+ "/api/internal/jobs/test-job-123/check-idle-reminder",
369
+ headers={"Authorization": "Bearer admin-token"}
370
+ )
371
+
372
+ # Should succeed and return "sent" status
373
+ assert response.status_code == 200
374
+ data = response.json()
375
+ assert data["status"] == "sent"
376
+ assert data["job_id"] == "test-job-123"
377
+
378
+ def test_skips_reminder_when_already_sent(self, mock_job):
379
+ """Test that reminder is skipped if already sent."""
380
+ from backend.api.routes.internal import router as internal_router
381
+ from backend.api.dependencies import require_admin
382
+
383
+ # Create test app for internal router
384
+ # The router already has prefix="/internal", so we add /api prefix
385
+ test_app = FastAPI()
386
+ test_app.include_router(internal_router, prefix="/api")
387
+ test_app.dependency_overrides[require_admin] = get_mock_admin
388
+
389
+ mock_job.status = JobStatus.AWAITING_REVIEW.value # Use .value for string comparison
390
+ mock_job.state_data = {
391
+ "blocking_state_entered_at": "2024-01-01T00:00:00",
392
+ "blocking_action_type": "lyrics",
393
+ "reminder_sent": True, # Already sent
394
+ }
395
+
396
+ with patch('backend.api.routes.internal.JobManager') as mock_jm_class:
397
+ mock_jm = Mock()
398
+ mock_jm.get_job.return_value = mock_job
399
+ mock_jm_class.return_value = mock_jm
400
+
401
+ test_client = TestClient(test_app)
402
+ response = test_client.post(
403
+ "/api/internal/jobs/test-job-123/check-idle-reminder",
404
+ headers={"Authorization": "Bearer admin-token"}
405
+ )
406
+
407
+ # Should succeed with "already_sent" status
408
+ assert response.status_code == 200
409
+ data = response.json()
410
+ assert data["status"] == "already_sent"
411
+ assert data["job_id"] == "test-job-123"