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,356 @@
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_requires_theme_id(self, job_manager, mock_firestore_service):
38
+ """Test that jobs without theme_id are rejected."""
39
+ job_create = JobCreate(
40
+ artist="Test Artist",
41
+ title="Test Song"
42
+ # No theme_id - should fail
43
+ )
44
+
45
+ with pytest.raises(ValueError, match="theme_id is required"):
46
+ job_manager.create_job(job_create)
47
+
48
+ # Verify Firestore was NOT called
49
+ mock_firestore_service.create_job.assert_not_called()
50
+
51
+ def test_create_job_with_url(self, job_manager, mock_firestore_service):
52
+ """Test creating a job with YouTube URL."""
53
+ job_create = JobCreate(
54
+ url="https://youtube.com/watch?v=test",
55
+ artist="Test Artist",
56
+ title="Test Song",
57
+ theme_id="nomad" # Required for all jobs
58
+ )
59
+
60
+ # The actual create_job method creates the job and returns it
61
+ job = job_manager.create_job(job_create)
62
+
63
+ assert job.job_id is not None
64
+ assert job.status == JobStatus.PENDING
65
+ assert job.url == "https://youtube.com/watch?v=test"
66
+ assert job.artist == "Test Artist"
67
+ assert job.title == "Test Song"
68
+ assert job.progress == 0
69
+
70
+ # Verify Firestore was called
71
+ mock_firestore_service.create_job.assert_called_once()
72
+
73
+ def test_create_job_without_url(self, job_manager, mock_firestore_service):
74
+ """Test creating a job without URL (for file upload)."""
75
+ job_create = JobCreate(
76
+ artist="Test Artist",
77
+ title="Test Song",
78
+ theme_id="nomad" # Required for all jobs
79
+ )
80
+
81
+ job = job_manager.create_job(job_create)
82
+
83
+ assert job.artist == "Test Artist"
84
+ assert job.title == "Test Song"
85
+ assert job.url is None
86
+
87
+ def test_create_job_generates_unique_id(self, job_manager, mock_firestore_service):
88
+ """Test that each job gets a unique ID."""
89
+ job_create = JobCreate(theme_id="nomad") # Required for all jobs
90
+
91
+ # Create multiple jobs
92
+ ids = []
93
+ for i in range(5):
94
+ mock_firestore_service.create_job.return_value = Job(
95
+ job_id=f"test{i}",
96
+ status=JobStatus.PENDING,
97
+ created_at=datetime.now(UTC),
98
+ updated_at=datetime.now(UTC)
99
+ )
100
+ job = job_manager.create_job(job_create)
101
+ ids.append(job.job_id)
102
+
103
+ # All IDs should be unique
104
+ assert len(ids) == len(set(ids))
105
+
106
+ def test_create_job_sets_initial_status(self, job_manager, mock_firestore_service):
107
+ """Test that new jobs start with PENDING status."""
108
+ job_create = JobCreate(theme_id="nomad") # Required for all jobs
109
+
110
+ mock_firestore_service.create_job.return_value = Job(
111
+ job_id="test123",
112
+ status=JobStatus.PENDING,
113
+ created_at=datetime.now(UTC),
114
+ updated_at=datetime.now(UTC)
115
+ )
116
+
117
+ job = job_manager.create_job(job_create)
118
+
119
+ assert job.status == JobStatus.PENDING
120
+ assert job.progress == 0
121
+
122
+ def test_create_job_with_distribution_settings(self, job_manager, mock_firestore_service):
123
+ """Test that distribution settings are passed from JobCreate to Job.
124
+
125
+ This was a bug where brand_prefix, dropbox_path, gdrive_folder_id, and
126
+ discord_webhook_url were NOT being passed to the Job constructor.
127
+ """
128
+ job_create = JobCreate(
129
+ artist="Test Artist",
130
+ title="Test Song",
131
+ theme_id="nomad", # Required for all jobs
132
+ brand_prefix="NOMAD",
133
+ discord_webhook_url="https://discord.com/webhook/test",
134
+ dropbox_path="/Karaoke/Tracks-Organized",
135
+ gdrive_folder_id="1abc123xyz",
136
+ enable_youtube_upload=True,
137
+ )
138
+
139
+ job = job_manager.create_job(job_create)
140
+
141
+ # Verify distribution settings are passed through
142
+ assert job.brand_prefix == "NOMAD"
143
+ assert job.discord_webhook_url == "https://discord.com/webhook/test"
144
+ assert job.dropbox_path == "/Karaoke/Tracks-Organized"
145
+ assert job.gdrive_folder_id == "1abc123xyz"
146
+ assert job.enable_youtube_upload is True
147
+
148
+ # Verify Firestore was called with job containing these fields
149
+ mock_firestore_service.create_job.assert_called_once()
150
+ created_job = mock_firestore_service.create_job.call_args[0][0]
151
+ assert created_job.brand_prefix == "NOMAD"
152
+ assert created_job.dropbox_path == "/Karaoke/Tracks-Organized"
153
+
154
+
155
+ class TestJobRetrieval:
156
+ """Test job retrieval logic."""
157
+
158
+ def test_get_existing_job(self, job_manager, mock_firestore_service):
159
+ """Test retrieving an existing job."""
160
+ expected_job = Job(
161
+ job_id="test123",
162
+ status=JobStatus.SEPARATING_STAGE1,
163
+ progress=25,
164
+ created_at=datetime.now(UTC),
165
+ updated_at=datetime.now(UTC)
166
+ )
167
+
168
+ mock_firestore_service.get_job.return_value = expected_job
169
+
170
+ job = job_manager.get_job("test123")
171
+
172
+ assert job.job_id == "test123"
173
+ assert job.status == JobStatus.SEPARATING_STAGE1
174
+ assert job.progress == 25
175
+
176
+ mock_firestore_service.get_job.assert_called_once_with("test123")
177
+
178
+ def test_get_nonexistent_job(self, job_manager, mock_firestore_service):
179
+ """Test retrieving a nonexistent job returns None."""
180
+ mock_firestore_service.get_job.return_value = None
181
+
182
+ job = job_manager.get_job("nonexistent")
183
+
184
+ assert job is None
185
+
186
+
187
+ class TestJobUpdate:
188
+ """Test job update logic."""
189
+
190
+ def test_update_job_status(self, job_manager, mock_firestore_service):
191
+ """Test updating job status."""
192
+ # Setup: job exists
193
+ existing_job = Job(
194
+ job_id="test123",
195
+ status=JobStatus.PENDING,
196
+ progress=0,
197
+ created_at=datetime.now(UTC),
198
+ updated_at=datetime.now(UTC)
199
+ )
200
+ mock_firestore_service.get_job.return_value = existing_job
201
+
202
+ # Update status
203
+ updates = {"status": JobStatus.SEPARATING_STAGE1, "progress": 25}
204
+ job_manager.update_job("test123", updates)
205
+
206
+ # Verify update was called
207
+ mock_firestore_service.update_job.assert_called_once()
208
+ call_args = mock_firestore_service.update_job.call_args
209
+ assert call_args[0][0] == "test123" # job_id
210
+ assert "status" in call_args[0][1] # updates dict
211
+
212
+ def test_update_job_multiple_fields(self, job_manager, mock_firestore_service):
213
+ """Test updating multiple fields at once."""
214
+ existing_job = Job(
215
+ job_id="test123",
216
+ status=JobStatus.PENDING,
217
+ progress=0,
218
+ created_at=datetime.now(UTC),
219
+ updated_at=datetime.now(UTC),
220
+ timeline=[]
221
+ )
222
+ mock_firestore_service.get_job.return_value = existing_job
223
+
224
+ updates = {"status": JobStatus.SEPARATING_STAGE1, "progress": 25}
225
+ job_manager.update_job("test123", updates)
226
+
227
+ # Verify update was called
228
+ call_args = mock_firestore_service.update_job.call_args
229
+ updates_dict = call_args[0][1]
230
+ assert "status" in updates_dict
231
+ assert "progress" in updates_dict
232
+
233
+ def test_update_input_media_gcs_path(self, job_manager, mock_firestore_service):
234
+ """Test updating input_media_gcs_path field."""
235
+ existing_job = Job(
236
+ job_id="test123",
237
+ status=JobStatus.PENDING,
238
+ progress=0,
239
+ created_at=datetime.now(UTC),
240
+ updated_at=datetime.now(UTC)
241
+ )
242
+ mock_firestore_service.get_job.return_value = existing_job
243
+
244
+ updates = {"input_media_gcs_path": "uploads/test123/file.flac"}
245
+ job_manager.update_job("test123", updates)
246
+
247
+ # Verify update was called with input_media_gcs_path
248
+ call_args = mock_firestore_service.update_job.call_args
249
+ updates_dict = call_args[0][1]
250
+ assert "input_media_gcs_path" in updates_dict
251
+ assert updates_dict["input_media_gcs_path"] == "uploads/test123/file.flac"
252
+
253
+
254
+ class TestJobStatusTransitions:
255
+ """Test valid job status transitions."""
256
+
257
+ def test_pending_to_downloading(self, job_manager, mock_firestore_service):
258
+ """Test transition from PENDING to DOWNLOADING."""
259
+ existing_job = Job(
260
+ job_id="test123",
261
+ status=JobStatus.PENDING,
262
+ progress=0,
263
+ created_at=datetime.now(UTC),
264
+ updated_at=datetime.now(UTC)
265
+ )
266
+ mock_firestore_service.get_job.return_value = existing_job
267
+
268
+ job_manager.update_job("test123", {"status": JobStatus.DOWNLOADING})
269
+
270
+ # Should succeed (valid transition)
271
+ mock_firestore_service.update_job.assert_called_once()
272
+
273
+ def test_downloading_to_separating(self, job_manager, mock_firestore_service):
274
+ """Test transition from DOWNLOADING to SEPARATING_STAGE1."""
275
+ existing_job = Job(
276
+ job_id="test123",
277
+ status=JobStatus.DOWNLOADING,
278
+ progress=0,
279
+ created_at=datetime.now(UTC),
280
+ updated_at=datetime.now(UTC)
281
+ )
282
+ mock_firestore_service.get_job.return_value = existing_job
283
+
284
+ job_manager.update_job("test123", {"status": JobStatus.SEPARATING_STAGE1})
285
+
286
+ mock_firestore_service.update_job.assert_called_once()
287
+
288
+
289
+ class TestJobFailure:
290
+ """Test job failure handling."""
291
+
292
+ def test_mark_job_as_failed(self, job_manager, mock_firestore_service):
293
+ """Test marking a job as failed."""
294
+ error_message = "Audio separation failed"
295
+ job_manager.mark_job_failed("test123", error_message)
296
+
297
+ # mark_job_failed calls firestore.update_job_status
298
+ mock_firestore_service.update_job_status.assert_called_once()
299
+ call_args = mock_firestore_service.update_job_status.call_args
300
+ assert call_args[1]['job_id'] == "test123"
301
+ assert call_args[1]['status'] == JobStatus.FAILED
302
+ assert 'error_message' in call_args[1]
303
+
304
+ def test_mark_job_error(self, job_manager, mock_firestore_service):
305
+ """Test marking a job with an error."""
306
+ error_message = "Test error"
307
+ job_manager.mark_job_error("test123", error_message)
308
+
309
+ # mark_job_error calls firestore.update_job_status
310
+ mock_firestore_service.update_job_status.assert_called_once()
311
+ call_args = mock_firestore_service.update_job_status.call_args
312
+ assert call_args[1]['job_id'] == "test123"
313
+ assert call_args[1]['error_message'] == error_message
314
+
315
+
316
+ class TestJobDeletion:
317
+ """Test job deletion logic."""
318
+
319
+ def test_delete_job(self, job_manager, mock_firestore_service):
320
+ """Test deleting a job."""
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")
332
+
333
+ # Verify delete was called
334
+ mock_firestore_service.delete_job.assert_called_once_with("test123")
335
+
336
+ def test_delete_job_with_files(self, job_manager, mock_firestore_service):
337
+ """Test deleting a job and its files."""
338
+ existing_job = Job(
339
+ job_id="test123",
340
+ status=JobStatus.COMPLETE,
341
+ progress=100,
342
+ created_at=datetime.now(UTC),
343
+ updated_at=datetime.now(UTC),
344
+ output_files={} # Empty dict so iteration works
345
+ )
346
+ mock_firestore_service.get_job.return_value = existing_job
347
+
348
+ job_manager.delete_job("test123", delete_files=True)
349
+
350
+ # Verify delete was called
351
+ mock_firestore_service.delete_job.assert_called_once_with("test123")
352
+
353
+
354
+ if __name__ == "__main__":
355
+ pytest.main([__file__, "-v"])
356
+
@@ -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)