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,339 @@
1
+ """
2
+ Unit tests for Job Manager service.
3
+
4
+ Tests the job lifecycle management without requiring actual Firestore connection.
5
+ Uses mocking to isolate the business logic.
6
+ """
7
+ import pytest
8
+ from unittest.mock import Mock, MagicMock, patch
9
+ from datetime import datetime, UTC
10
+
11
+ # Mock Firestore before importing JobManager
12
+ import sys
13
+ sys.modules['google.cloud.firestore'] = MagicMock()
14
+
15
+ from backend.services.job_manager import JobManager
16
+ from backend.models.job import Job, JobCreate, JobStatus
17
+
18
+
19
+ @pytest.fixture
20
+ def mock_firestore_service():
21
+ """Mock FirestoreService."""
22
+ with patch('backend.services.job_manager.FirestoreService') as mock:
23
+ service = Mock()
24
+ mock.return_value = service
25
+ yield service
26
+
27
+
28
+ @pytest.fixture
29
+ def job_manager(mock_firestore_service):
30
+ """Create JobManager with mocked dependencies."""
31
+ return JobManager()
32
+
33
+
34
+ class TestJobCreation:
35
+ """Test job creation logic."""
36
+
37
+ def test_create_job_with_url(self, job_manager, mock_firestore_service):
38
+ """Test creating a job with YouTube URL."""
39
+ job_create = JobCreate(
40
+ url="https://youtube.com/watch?v=test",
41
+ artist="Test Artist",
42
+ title="Test Song"
43
+ )
44
+
45
+ # The actual create_job method creates the job and returns it
46
+ job = job_manager.create_job(job_create)
47
+
48
+ assert job.job_id is not None
49
+ assert job.status == JobStatus.PENDING
50
+ assert job.url == "https://youtube.com/watch?v=test"
51
+ assert job.artist == "Test Artist"
52
+ assert job.title == "Test Song"
53
+ assert job.progress == 0
54
+
55
+ # Verify Firestore was called
56
+ mock_firestore_service.create_job.assert_called_once()
57
+
58
+ def test_create_job_without_url(self, job_manager, mock_firestore_service):
59
+ """Test creating a job without URL (for file upload)."""
60
+ job_create = JobCreate(
61
+ artist="Test Artist",
62
+ title="Test Song"
63
+ )
64
+
65
+ job = job_manager.create_job(job_create)
66
+
67
+ assert job.artist == "Test Artist"
68
+ assert job.title == "Test Song"
69
+ assert job.url is None
70
+
71
+ def test_create_job_generates_unique_id(self, job_manager, mock_firestore_service):
72
+ """Test that each job gets a unique ID."""
73
+ job_create = JobCreate()
74
+
75
+ # Create multiple jobs
76
+ ids = []
77
+ for i in range(5):
78
+ mock_firestore_service.create_job.return_value = Job(
79
+ job_id=f"test{i}",
80
+ status=JobStatus.PENDING,
81
+ created_at=datetime.now(UTC),
82
+ updated_at=datetime.now(UTC)
83
+ )
84
+ job = job_manager.create_job(job_create)
85
+ ids.append(job.job_id)
86
+
87
+ # All IDs should be unique
88
+ assert len(ids) == len(set(ids))
89
+
90
+ def test_create_job_sets_initial_status(self, job_manager, mock_firestore_service):
91
+ """Test that new jobs start with PENDING status."""
92
+ job_create = JobCreate()
93
+
94
+ mock_firestore_service.create_job.return_value = Job(
95
+ job_id="test123",
96
+ status=JobStatus.PENDING,
97
+ created_at=datetime.now(UTC),
98
+ updated_at=datetime.now(UTC)
99
+ )
100
+
101
+ job = job_manager.create_job(job_create)
102
+
103
+ assert job.status == JobStatus.PENDING
104
+ assert job.progress == 0
105
+
106
+ def test_create_job_with_distribution_settings(self, job_manager, mock_firestore_service):
107
+ """Test that distribution settings are passed from JobCreate to Job.
108
+
109
+ This was a bug where brand_prefix, dropbox_path, gdrive_folder_id, and
110
+ discord_webhook_url were NOT being passed to the Job constructor.
111
+ """
112
+ job_create = JobCreate(
113
+ artist="Test Artist",
114
+ title="Test Song",
115
+ brand_prefix="NOMAD",
116
+ discord_webhook_url="https://discord.com/webhook/test",
117
+ dropbox_path="/Karaoke/Tracks-Organized",
118
+ gdrive_folder_id="1abc123xyz",
119
+ enable_youtube_upload=True,
120
+ )
121
+
122
+ job = job_manager.create_job(job_create)
123
+
124
+ # Verify distribution settings are passed through
125
+ assert job.brand_prefix == "NOMAD"
126
+ assert job.discord_webhook_url == "https://discord.com/webhook/test"
127
+ assert job.dropbox_path == "/Karaoke/Tracks-Organized"
128
+ assert job.gdrive_folder_id == "1abc123xyz"
129
+ assert job.enable_youtube_upload is True
130
+
131
+ # Verify Firestore was called with job containing these fields
132
+ mock_firestore_service.create_job.assert_called_once()
133
+ created_job = mock_firestore_service.create_job.call_args[0][0]
134
+ assert created_job.brand_prefix == "NOMAD"
135
+ assert created_job.dropbox_path == "/Karaoke/Tracks-Organized"
136
+
137
+
138
+ class TestJobRetrieval:
139
+ """Test job retrieval logic."""
140
+
141
+ def test_get_existing_job(self, job_manager, mock_firestore_service):
142
+ """Test retrieving an existing job."""
143
+ expected_job = Job(
144
+ job_id="test123",
145
+ status=JobStatus.SEPARATING_STAGE1,
146
+ progress=25,
147
+ created_at=datetime.now(UTC),
148
+ updated_at=datetime.now(UTC)
149
+ )
150
+
151
+ mock_firestore_service.get_job.return_value = expected_job
152
+
153
+ job = job_manager.get_job("test123")
154
+
155
+ assert job.job_id == "test123"
156
+ assert job.status == JobStatus.SEPARATING_STAGE1
157
+ assert job.progress == 25
158
+
159
+ mock_firestore_service.get_job.assert_called_once_with("test123")
160
+
161
+ def test_get_nonexistent_job(self, job_manager, mock_firestore_service):
162
+ """Test retrieving a nonexistent job returns None."""
163
+ mock_firestore_service.get_job.return_value = None
164
+
165
+ job = job_manager.get_job("nonexistent")
166
+
167
+ assert job is None
168
+
169
+
170
+ class TestJobUpdate:
171
+ """Test job update logic."""
172
+
173
+ def test_update_job_status(self, job_manager, mock_firestore_service):
174
+ """Test updating job status."""
175
+ # Setup: job exists
176
+ existing_job = Job(
177
+ job_id="test123",
178
+ status=JobStatus.PENDING,
179
+ progress=0,
180
+ created_at=datetime.now(UTC),
181
+ updated_at=datetime.now(UTC)
182
+ )
183
+ mock_firestore_service.get_job.return_value = existing_job
184
+
185
+ # Update status
186
+ updates = {"status": JobStatus.SEPARATING_STAGE1, "progress": 25}
187
+ job_manager.update_job("test123", updates)
188
+
189
+ # Verify update was called
190
+ mock_firestore_service.update_job.assert_called_once()
191
+ call_args = mock_firestore_service.update_job.call_args
192
+ assert call_args[0][0] == "test123" # job_id
193
+ assert "status" in call_args[0][1] # updates dict
194
+
195
+ def test_update_job_multiple_fields(self, job_manager, mock_firestore_service):
196
+ """Test updating multiple fields at once."""
197
+ existing_job = Job(
198
+ job_id="test123",
199
+ status=JobStatus.PENDING,
200
+ progress=0,
201
+ created_at=datetime.now(UTC),
202
+ updated_at=datetime.now(UTC),
203
+ timeline=[]
204
+ )
205
+ mock_firestore_service.get_job.return_value = existing_job
206
+
207
+ updates = {"status": JobStatus.SEPARATING_STAGE1, "progress": 25}
208
+ job_manager.update_job("test123", updates)
209
+
210
+ # Verify update was called
211
+ call_args = mock_firestore_service.update_job.call_args
212
+ updates_dict = call_args[0][1]
213
+ assert "status" in updates_dict
214
+ assert "progress" in updates_dict
215
+
216
+ def test_update_input_media_gcs_path(self, job_manager, mock_firestore_service):
217
+ """Test updating input_media_gcs_path field."""
218
+ existing_job = Job(
219
+ job_id="test123",
220
+ status=JobStatus.PENDING,
221
+ progress=0,
222
+ created_at=datetime.now(UTC),
223
+ updated_at=datetime.now(UTC)
224
+ )
225
+ mock_firestore_service.get_job.return_value = existing_job
226
+
227
+ updates = {"input_media_gcs_path": "uploads/test123/file.flac"}
228
+ job_manager.update_job("test123", updates)
229
+
230
+ # Verify update was called with input_media_gcs_path
231
+ call_args = mock_firestore_service.update_job.call_args
232
+ updates_dict = call_args[0][1]
233
+ assert "input_media_gcs_path" in updates_dict
234
+ assert updates_dict["input_media_gcs_path"] == "uploads/test123/file.flac"
235
+
236
+
237
+ class TestJobStatusTransitions:
238
+ """Test valid job status transitions."""
239
+
240
+ def test_pending_to_downloading(self, job_manager, mock_firestore_service):
241
+ """Test transition from PENDING to DOWNLOADING."""
242
+ existing_job = Job(
243
+ job_id="test123",
244
+ status=JobStatus.PENDING,
245
+ progress=0,
246
+ created_at=datetime.now(UTC),
247
+ updated_at=datetime.now(UTC)
248
+ )
249
+ mock_firestore_service.get_job.return_value = existing_job
250
+
251
+ job_manager.update_job("test123", {"status": JobStatus.DOWNLOADING})
252
+
253
+ # Should succeed (valid transition)
254
+ mock_firestore_service.update_job.assert_called_once()
255
+
256
+ def test_downloading_to_separating(self, job_manager, mock_firestore_service):
257
+ """Test transition from DOWNLOADING to SEPARATING_STAGE1."""
258
+ existing_job = Job(
259
+ job_id="test123",
260
+ status=JobStatus.DOWNLOADING,
261
+ progress=0,
262
+ created_at=datetime.now(UTC),
263
+ updated_at=datetime.now(UTC)
264
+ )
265
+ mock_firestore_service.get_job.return_value = existing_job
266
+
267
+ job_manager.update_job("test123", {"status": JobStatus.SEPARATING_STAGE1})
268
+
269
+ mock_firestore_service.update_job.assert_called_once()
270
+
271
+
272
+ class TestJobFailure:
273
+ """Test job failure handling."""
274
+
275
+ def test_mark_job_as_failed(self, job_manager, mock_firestore_service):
276
+ """Test marking a job as failed."""
277
+ error_message = "Audio separation failed"
278
+ job_manager.mark_job_failed("test123", error_message)
279
+
280
+ # mark_job_failed calls firestore.update_job_status
281
+ mock_firestore_service.update_job_status.assert_called_once()
282
+ call_args = mock_firestore_service.update_job_status.call_args
283
+ assert call_args[1]['job_id'] == "test123"
284
+ assert call_args[1]['status'] == JobStatus.FAILED
285
+ assert 'error_message' in call_args[1]
286
+
287
+ def test_mark_job_error(self, job_manager, mock_firestore_service):
288
+ """Test marking a job with an error."""
289
+ error_message = "Test error"
290
+ job_manager.mark_job_error("test123", error_message)
291
+
292
+ # mark_job_error calls firestore.update_job_status
293
+ mock_firestore_service.update_job_status.assert_called_once()
294
+ call_args = mock_firestore_service.update_job_status.call_args
295
+ assert call_args[1]['job_id'] == "test123"
296
+ assert call_args[1]['error_message'] == error_message
297
+
298
+
299
+ class TestJobDeletion:
300
+ """Test job deletion logic."""
301
+
302
+ def test_delete_job(self, job_manager, mock_firestore_service):
303
+ """Test deleting a job."""
304
+ existing_job = Job(
305
+ job_id="test123",
306
+ status=JobStatus.COMPLETE,
307
+ progress=100,
308
+ created_at=datetime.now(UTC),
309
+ updated_at=datetime.now(UTC),
310
+ output_files={} # Empty dict so iteration works
311
+ )
312
+ mock_firestore_service.get_job.return_value = existing_job
313
+
314
+ job_manager.delete_job("test123")
315
+
316
+ # Verify delete was called
317
+ mock_firestore_service.delete_job.assert_called_once_with("test123")
318
+
319
+ def test_delete_job_with_files(self, job_manager, mock_firestore_service):
320
+ """Test deleting a job and its files."""
321
+ existing_job = Job(
322
+ job_id="test123",
323
+ status=JobStatus.COMPLETE,
324
+ progress=100,
325
+ created_at=datetime.now(UTC),
326
+ updated_at=datetime.now(UTC),
327
+ output_files={} # Empty dict so iteration works
328
+ )
329
+ mock_firestore_service.get_job.return_value = existing_job
330
+
331
+ job_manager.delete_job("test123", delete_files=True)
332
+
333
+ # Verify delete was called
334
+ mock_firestore_service.delete_job.assert_called_once_with("test123")
335
+
336
+
337
+ if __name__ == "__main__":
338
+ pytest.main([__file__, "-v"])
339
+
@@ -0,0 +1,329 @@
1
+ """
2
+ Unit tests for job manager notification methods.
3
+
4
+ Tests the state transition triggers for email notifications.
5
+ """
6
+ import pytest
7
+ from unittest.mock import Mock, patch, MagicMock, AsyncMock
8
+ from datetime import datetime
9
+
10
+ from backend.services.job_manager import JobManager
11
+ from backend.models.job import Job, JobStatus
12
+
13
+
14
+ class TestTriggerStateNotifications:
15
+ """Tests for _trigger_state_notifications method."""
16
+
17
+ def test_triggers_completion_email_on_complete(self):
18
+ """Test that completion email is triggered when job completes."""
19
+ manager = JobManager()
20
+ manager.firestore = Mock()
21
+
22
+ mock_job = Mock(spec=Job)
23
+ mock_job.job_id = "job-123"
24
+ mock_job.user_email = "user@example.com"
25
+ mock_job.state_data = {"youtube_url": "https://youtube.com/123"}
26
+ manager.get_job = Mock(return_value=mock_job)
27
+
28
+ with patch.object(manager, '_schedule_completion_email') as mock_schedule:
29
+ manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
30
+
31
+ mock_schedule.assert_called_once_with(mock_job)
32
+
33
+ def test_triggers_idle_reminder_on_awaiting_review(self):
34
+ """Test that idle reminder is triggered when job enters awaiting review."""
35
+ manager = JobManager()
36
+ manager.firestore = Mock()
37
+
38
+ mock_job = Mock(spec=Job)
39
+ mock_job.job_id = "job-123"
40
+ mock_job.user_email = "user@example.com"
41
+ mock_job.state_data = {}
42
+ manager.get_job = Mock(return_value=mock_job)
43
+
44
+ with patch.object(manager, '_schedule_idle_reminder') as mock_schedule:
45
+ manager._trigger_state_notifications("job-123", JobStatus.AWAITING_REVIEW)
46
+
47
+ mock_schedule.assert_called_once_with(mock_job, JobStatus.AWAITING_REVIEW)
48
+
49
+ def test_triggers_idle_reminder_on_awaiting_instrumental(self):
50
+ """Test that idle reminder is triggered when job enters awaiting instrumental."""
51
+ manager = JobManager()
52
+ manager.firestore = Mock()
53
+
54
+ mock_job = Mock(spec=Job)
55
+ mock_job.job_id = "job-123"
56
+ mock_job.user_email = "user@example.com"
57
+ mock_job.state_data = {}
58
+ manager.get_job = Mock(return_value=mock_job)
59
+
60
+ with patch.object(manager, '_schedule_idle_reminder') as mock_schedule:
61
+ manager._trigger_state_notifications(
62
+ "job-123",
63
+ JobStatus.AWAITING_INSTRUMENTAL_SELECTION
64
+ )
65
+
66
+ mock_schedule.assert_called_once_with(
67
+ mock_job,
68
+ JobStatus.AWAITING_INSTRUMENTAL_SELECTION
69
+ )
70
+
71
+ def test_skips_notification_when_no_job(self):
72
+ """Test that notification is skipped when job not found."""
73
+ manager = JobManager()
74
+ manager.firestore = Mock()
75
+ manager.get_job = Mock(return_value=None)
76
+
77
+ with patch.object(manager, '_schedule_completion_email') as mock_schedule:
78
+ manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
79
+
80
+ mock_schedule.assert_not_called()
81
+
82
+ def test_skips_notification_when_no_user_email(self):
83
+ """Test that notification is skipped when job has no user email."""
84
+ manager = JobManager()
85
+ manager.firestore = Mock()
86
+
87
+ mock_job = Mock(spec=Job)
88
+ mock_job.job_id = "job-123"
89
+ mock_job.user_email = None
90
+ manager.get_job = Mock(return_value=mock_job)
91
+
92
+ with patch.object(manager, '_schedule_completion_email') as mock_schedule:
93
+ manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
94
+
95
+ mock_schedule.assert_not_called()
96
+
97
+ def test_does_not_trigger_on_other_states(self):
98
+ """Test that no notification is triggered for non-notification states."""
99
+ manager = JobManager()
100
+ manager.firestore = Mock()
101
+
102
+ mock_job = Mock(spec=Job)
103
+ mock_job.job_id = "job-123"
104
+ mock_job.user_email = "user@example.com"
105
+ mock_job.state_data = {}
106
+ manager.get_job = Mock(return_value=mock_job)
107
+
108
+ with patch.object(manager, '_schedule_completion_email') as mock_complete, \
109
+ patch.object(manager, '_schedule_idle_reminder') as mock_idle:
110
+
111
+ # Test various non-notification states
112
+ for status in [JobStatus.PENDING, JobStatus.DOWNLOADING, JobStatus.TRANSCRIBING,
113
+ JobStatus.RENDERING_VIDEO, JobStatus.FAILED]:
114
+ manager._trigger_state_notifications("job-123", status)
115
+
116
+ mock_complete.assert_not_called()
117
+ mock_idle.assert_not_called()
118
+
119
+ def test_handles_exception_gracefully(self):
120
+ """Test that exceptions don't propagate from notifications."""
121
+ manager = JobManager()
122
+ manager.firestore = Mock()
123
+ manager.get_job = Mock(side_effect=Exception("Database error"))
124
+
125
+ # Should not raise
126
+ manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
127
+
128
+
129
+ class TestScheduleCompletionEmail:
130
+ """Tests for _schedule_completion_email method."""
131
+
132
+ def test_extracts_urls_from_state_data(self):
133
+ """Test that YouTube and Dropbox URLs are extracted from state_data."""
134
+ manager = JobManager()
135
+ manager.firestore = Mock()
136
+
137
+ mock_job = Mock(spec=Job)
138
+ mock_job.job_id = "job-123"
139
+ mock_job.user_email = "user@example.com"
140
+ mock_job.artist = "Test Artist"
141
+ mock_job.title = "Test Song"
142
+ mock_job.state_data = {
143
+ "youtube_url": "https://youtube.com/watch?v=123",
144
+ "dropbox_link": "https://dropbox.com/folder/abc",
145
+ }
146
+
147
+ with patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_service:
148
+ mock_service = Mock()
149
+ mock_service.send_job_completion_email = AsyncMock(return_value=True)
150
+ mock_get_service.return_value = mock_service
151
+
152
+ # Run in sync context - this will start a daemon thread
153
+ manager._schedule_completion_email(mock_job)
154
+
155
+ # Give the thread a moment to execute
156
+ import time
157
+ time.sleep(0.1)
158
+
159
+ def test_handles_none_state_data(self):
160
+ """Test that None state_data is handled gracefully."""
161
+ manager = JobManager()
162
+ manager.firestore = Mock()
163
+
164
+ mock_job = Mock(spec=Job)
165
+ mock_job.job_id = "job-123"
166
+ mock_job.user_email = "user@example.com"
167
+ mock_job.artist = None
168
+ mock_job.title = None
169
+ mock_job.state_data = None # None state_data
170
+
171
+ with patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_service:
172
+ mock_service = Mock()
173
+ mock_service.send_job_completion_email = AsyncMock(return_value=True)
174
+ mock_get_service.return_value = mock_service
175
+
176
+ # Should not raise
177
+ manager._schedule_completion_email(mock_job)
178
+
179
+ def test_handles_exception_gracefully(self):
180
+ """Test that exceptions don't propagate."""
181
+ manager = JobManager()
182
+ manager.firestore = Mock()
183
+
184
+ mock_job = Mock(spec=Job)
185
+ mock_job.job_id = "job-123"
186
+ mock_job.user_email = "user@example.com"
187
+ mock_job.state_data = {}
188
+
189
+ with patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_service:
190
+ mock_get_service.side_effect = Exception("Service error")
191
+
192
+ # Should not raise
193
+ manager._schedule_completion_email(mock_job)
194
+
195
+
196
+ class TestScheduleIdleReminder:
197
+ """Tests for _schedule_idle_reminder method."""
198
+
199
+ def test_updates_state_data_for_lyrics_review(self):
200
+ """Test that state_data is updated with blocking state info for lyrics."""
201
+ manager = JobManager()
202
+ manager.firestore = Mock()
203
+
204
+ mock_job = Mock(spec=Job)
205
+ mock_job.job_id = "job-123"
206
+ mock_job.user_email = "user@example.com"
207
+ mock_job.state_data = {"existing_key": "value"}
208
+
209
+ with patch('backend.services.worker_service.get_worker_service') as mock_get_service:
210
+ mock_service = Mock()
211
+ mock_service.schedule_idle_reminder = AsyncMock(return_value=True)
212
+ mock_get_service.return_value = mock_service
213
+
214
+ manager._schedule_idle_reminder(mock_job, JobStatus.AWAITING_REVIEW)
215
+
216
+ # Verify state_data was updated
217
+ update_call = manager.firestore.update_job.call_args
218
+ assert update_call is not None
219
+ updated_data = update_call[0][1] # Second positional arg
220
+ state_data = updated_data.get('state_data', {})
221
+
222
+ assert state_data.get('blocking_action_type') == 'lyrics'
223
+ assert state_data.get('reminder_sent') is False
224
+ assert 'blocking_state_entered_at' in state_data
225
+ assert state_data.get('existing_key') == 'value' # Preserved
226
+
227
+ def test_updates_state_data_for_instrumental_selection(self):
228
+ """Test that state_data is updated with blocking state info for instrumental."""
229
+ manager = JobManager()
230
+ manager.firestore = Mock()
231
+
232
+ mock_job = Mock(spec=Job)
233
+ mock_job.job_id = "job-123"
234
+ mock_job.user_email = "user@example.com"
235
+ mock_job.state_data = {}
236
+
237
+ with patch('backend.services.worker_service.get_worker_service') as mock_get_service:
238
+ mock_service = Mock()
239
+ mock_service.schedule_idle_reminder = AsyncMock(return_value=True)
240
+ mock_get_service.return_value = mock_service
241
+
242
+ manager._schedule_idle_reminder(
243
+ mock_job,
244
+ JobStatus.AWAITING_INSTRUMENTAL_SELECTION
245
+ )
246
+
247
+ # Verify state_data was updated
248
+ update_call = manager.firestore.update_job.call_args
249
+ updated_data = update_call[0][1]
250
+ state_data = updated_data.get('state_data', {})
251
+
252
+ assert state_data.get('blocking_action_type') == 'instrumental'
253
+
254
+ def test_handles_none_state_data(self):
255
+ """Test that None state_data is handled gracefully."""
256
+ manager = JobManager()
257
+ manager.firestore = Mock()
258
+
259
+ mock_job = Mock(spec=Job)
260
+ mock_job.job_id = "job-123"
261
+ mock_job.user_email = "user@example.com"
262
+ mock_job.state_data = None
263
+
264
+ with patch('backend.services.worker_service.get_worker_service') as mock_get_service:
265
+ mock_service = Mock()
266
+ mock_service.schedule_idle_reminder = AsyncMock(return_value=True)
267
+ mock_get_service.return_value = mock_service
268
+
269
+ # Should not raise
270
+ manager._schedule_idle_reminder(mock_job, JobStatus.AWAITING_REVIEW)
271
+
272
+ # Verify update was still called
273
+ manager.firestore.update_job.assert_called_once()
274
+
275
+ def test_handles_exception_gracefully(self):
276
+ """Test that exceptions don't propagate."""
277
+ manager = JobManager()
278
+ manager.firestore = Mock()
279
+
280
+ mock_job = Mock(spec=Job)
281
+ mock_job.job_id = "job-123"
282
+ mock_job.user_email = "user@example.com"
283
+ mock_job.state_data = {}
284
+
285
+ # Make firestore raise an exception
286
+ manager.firestore.update_job.side_effect = Exception("Database error")
287
+
288
+ # Should not raise
289
+ manager._schedule_idle_reminder(mock_job, JobStatus.AWAITING_REVIEW)
290
+
291
+
292
+ class TestTransitionTriggersNotifications:
293
+ """Integration tests for transition_to_state triggering notifications."""
294
+
295
+ def test_transition_to_complete_triggers_notification(self):
296
+ """Test that transitioning to COMPLETE triggers notification."""
297
+ manager = JobManager()
298
+ manager.firestore = Mock()
299
+
300
+ mock_job = Mock(spec=Job)
301
+ mock_job.job_id = "job-123"
302
+ # Use ENCODING as the starting state since ENCODING -> COMPLETE is valid
303
+ mock_job.status = JobStatus.ENCODING
304
+ mock_job.user_email = "user@example.com"
305
+ mock_job.state_data = {}
306
+ manager.get_job = Mock(return_value=mock_job)
307
+
308
+ with patch.object(manager, '_trigger_state_notifications') as mock_trigger:
309
+ manager.transition_to_state("job-123", JobStatus.COMPLETE)
310
+
311
+ mock_trigger.assert_called_once_with("job-123", JobStatus.COMPLETE)
312
+
313
+ def test_transition_to_awaiting_review_triggers_notification(self):
314
+ """Test that transitioning to AWAITING_REVIEW triggers notification."""
315
+ manager = JobManager()
316
+ manager.firestore = Mock()
317
+
318
+ mock_job = Mock(spec=Job)
319
+ mock_job.job_id = "job-123"
320
+ # Use APPLYING_PADDING as the starting state since APPLYING_PADDING -> AWAITING_REVIEW is valid
321
+ mock_job.status = JobStatus.APPLYING_PADDING
322
+ mock_job.user_email = "user@example.com"
323
+ mock_job.state_data = {}
324
+ manager.get_job = Mock(return_value=mock_job)
325
+
326
+ with patch.object(manager, '_trigger_state_notifications') as mock_trigger:
327
+ manager.transition_to_state("job-123", JobStatus.AWAITING_REVIEW)
328
+
329
+ mock_trigger.assert_called_once_with("job-123", JobStatus.AWAITING_REVIEW)