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,556 @@
1
+ """
2
+ Unit tests for service layer.
3
+
4
+ Tests AuthService, StorageService, WorkerService, and FirestoreService
5
+ without requiring actual cloud resources.
6
+ """
7
+ import pytest
8
+ from unittest.mock import Mock, MagicMock, AsyncMock, patch, call
9
+ from datetime import datetime, UTC
10
+
11
+ # Mock Google Cloud before imports
12
+ import sys
13
+ sys.modules['google.cloud.firestore'] = MagicMock()
14
+ sys.modules['google.cloud.storage'] = MagicMock()
15
+ sys.modules['google.cloud.tasks_v2'] = MagicMock()
16
+
17
+ from backend.services.auth_service import AuthService, UserType
18
+ from backend.services.storage_service import StorageService
19
+ from backend.services.worker_service import WorkerService, get_worker_service
20
+ from backend.services.firestore_service import FirestoreService
21
+ from backend.models.job import Job, JobStatus
22
+
23
+
24
+ class TestAuthService:
25
+ """Test AuthService token validation and management."""
26
+
27
+ def test_validate_admin_token(self):
28
+ """Test validating hardcoded admin tokens."""
29
+ with patch('backend.services.auth_service.get_settings') as mock_settings:
30
+ mock_settings.return_value.admin_tokens = "admin123,secret456"
31
+
32
+ auth_service = AuthService()
33
+
34
+ # Valid admin token - returns (is_valid, user_type, usage_count, token)
35
+ is_valid, user_type, usage_count, token = auth_service.validate_token('admin123')
36
+ assert is_valid is True
37
+ assert user_type == UserType.ADMIN
38
+
39
+ # Another valid admin token
40
+ is_valid, user_type, _, _ = auth_service.validate_token('secret456')
41
+ assert is_valid is True
42
+ assert user_type == UserType.ADMIN
43
+
44
+ def test_validate_invalid_token(self):
45
+ """Test rejecting invalid tokens."""
46
+ with patch('backend.services.auth_service.FirestoreService') as mock_fs:
47
+ mock_fs_instance = Mock()
48
+ mock_fs.return_value = mock_fs_instance
49
+ mock_fs_instance.get_token.return_value = None # Token not in DB
50
+
51
+ with patch('backend.services.auth_service.get_settings') as mock_settings:
52
+ mock_settings.return_value.admin_tokens = "admin123"
53
+
54
+ auth_service = AuthService()
55
+ is_valid, _, _, _ = auth_service.validate_token('invalid_token')
56
+
57
+ assert is_valid is False
58
+
59
+ def test_validate_token_from_firestore(self):
60
+ """Test validating tokens stored in Firestore."""
61
+ with patch('backend.services.auth_service.FirestoreService') as mock_fs:
62
+ mock_fs_instance = Mock()
63
+ mock_fs.return_value = mock_fs_instance
64
+
65
+ # Mock Firestore returning a token
66
+ mock_fs_instance.get_token.return_value = {
67
+ 'token': 'db_token123',
68
+ 'type': 'unlimited',
69
+ 'valid': True,
70
+ 'usage_count': 5,
71
+ 'created_at': datetime.now(UTC).isoformat()
72
+ }
73
+
74
+ with patch('backend.services.auth_service.get_settings') as mock_settings:
75
+ mock_settings.return_value.admin_tokens = ""
76
+
77
+ auth_service = AuthService()
78
+ is_valid, user_type, usage_count, token = auth_service.validate_token('db_token123')
79
+
80
+ assert is_valid is True
81
+ assert user_type == UserType.UNLIMITED
82
+ # usage_count can be -1 for unlimited tokens
83
+ assert isinstance(usage_count, int)
84
+
85
+
86
+ class TestStorageService:
87
+ """Test StorageService GCS operations."""
88
+
89
+ def test_upload_file(self):
90
+ """Test uploading a file to GCS."""
91
+ with patch('backend.services.storage_service.storage') as mock_storage:
92
+ mock_client = MagicMock()
93
+ mock_storage.Client.return_value = mock_client
94
+
95
+ mock_bucket = MagicMock()
96
+ mock_client.bucket.return_value = mock_bucket
97
+
98
+ mock_blob = MagicMock()
99
+ mock_bucket.blob.return_value = mock_blob
100
+
101
+ storage_service = StorageService()
102
+
103
+ # Upload file - correct parameter names
104
+ result = storage_service.upload_file(
105
+ local_path="/tmp/test.flac",
106
+ destination_path="uploads/test123/test.flac"
107
+ )
108
+
109
+ # Verify blob was created and uploaded
110
+ mock_bucket.blob.assert_called_once_with("uploads/test123/test.flac")
111
+ mock_blob.upload_from_filename.assert_called_once_with("/tmp/test.flac")
112
+ # Result should be the destination path
113
+ assert result == "uploads/test123/test.flac"
114
+
115
+ def test_download_file(self):
116
+ """Test downloading a file from GCS."""
117
+ with patch('backend.services.storage_service.storage') as mock_storage:
118
+ mock_client = MagicMock()
119
+ mock_storage.Client.return_value = mock_client
120
+
121
+ mock_bucket = MagicMock()
122
+ mock_client.bucket.return_value = mock_bucket
123
+
124
+ mock_blob = MagicMock()
125
+ mock_bucket.blob.return_value = mock_blob
126
+
127
+ storage_service = StorageService()
128
+
129
+ # Download file - correct parameter names
130
+ storage_service.download_file(
131
+ source_path="uploads/test123/test.flac",
132
+ destination_path="/tmp/downloaded.flac"
133
+ )
134
+
135
+ # Verify download was called
136
+ mock_blob.download_to_filename.assert_called_once_with("/tmp/downloaded.flac")
137
+
138
+ def test_delete_file(self):
139
+ """Test deleting a file from GCS."""
140
+ with patch('backend.services.storage_service.storage') as mock_storage:
141
+ mock_client = MagicMock()
142
+ mock_storage.Client.return_value = mock_client
143
+
144
+ mock_bucket = MagicMock()
145
+ mock_client.bucket.return_value = mock_bucket
146
+
147
+ mock_blob = MagicMock()
148
+ mock_bucket.blob.return_value = mock_blob
149
+
150
+ storage_service = StorageService()
151
+
152
+ # Delete file
153
+ storage_service.delete_file("uploads/test123/test.flac")
154
+
155
+ # Verify delete was called
156
+ mock_blob.delete.assert_called_once()
157
+
158
+
159
+ class TestWorkerService:
160
+ """Test WorkerService internal HTTP calls."""
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_trigger_audio_worker(self):
164
+ """Test triggering audio worker via internal HTTP."""
165
+ with patch('backend.services.worker_service.httpx.AsyncClient') as mock_client_cls:
166
+ mock_client = AsyncMock()
167
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
168
+
169
+ mock_response = Mock()
170
+ mock_response.status_code = 200
171
+ mock_client.post.return_value = mock_response
172
+
173
+ worker_service = get_worker_service()
174
+
175
+ await worker_service.trigger_audio_worker("test123")
176
+
177
+ # Verify HTTP POST was made
178
+ mock_client.post.assert_called_once()
179
+ call_args = mock_client.post.call_args
180
+ assert "/api/internal/workers/audio" in str(call_args)
181
+
182
+ @pytest.mark.asyncio
183
+ async def test_trigger_lyrics_worker(self):
184
+ """Test triggering lyrics worker via internal HTTP."""
185
+ with patch('backend.services.worker_service.httpx.AsyncClient') as mock_client_cls:
186
+ mock_client = AsyncMock()
187
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
188
+
189
+ mock_response = Mock()
190
+ mock_response.status_code = 200
191
+ mock_client.post.return_value = mock_response
192
+
193
+ worker_service = get_worker_service()
194
+
195
+ await worker_service.trigger_lyrics_worker("test123")
196
+
197
+ # Verify HTTP POST was made
198
+ mock_client.post.assert_called_once()
199
+ call_args = mock_client.post.call_args
200
+ assert "/api/internal/workers/lyrics" in str(call_args)
201
+
202
+ @pytest.mark.asyncio
203
+ async def test_trigger_screens_worker(self):
204
+ """Test triggering screens worker via internal HTTP."""
205
+ with patch('backend.services.worker_service.httpx.AsyncClient') as mock_client_cls:
206
+ mock_client = AsyncMock()
207
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
208
+
209
+ mock_response = Mock()
210
+ mock_response.status_code = 200
211
+ mock_client.post.return_value = mock_response
212
+
213
+ worker_service = get_worker_service()
214
+
215
+ await worker_service.trigger_screens_worker("test123")
216
+
217
+ # Verify HTTP POST was made
218
+ mock_client.post.assert_called_once()
219
+ call_args = mock_client.post.call_args
220
+ assert "/api/internal/workers/screens" in str(call_args)
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_trigger_video_worker(self):
224
+ """Test triggering video worker via internal HTTP."""
225
+ with patch('backend.services.worker_service.httpx.AsyncClient') as mock_client_cls:
226
+ mock_client = AsyncMock()
227
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
228
+
229
+ mock_response = Mock()
230
+ mock_response.status_code = 200
231
+ mock_client.post.return_value = mock_response
232
+
233
+ worker_service = get_worker_service()
234
+
235
+ await worker_service.trigger_video_worker("test123")
236
+
237
+ # Verify HTTP POST was made
238
+ mock_client.post.assert_called_once()
239
+ call_args = mock_client.post.call_args
240
+ assert "/api/internal/workers/video" in str(call_args)
241
+
242
+
243
+ class TestWorkerServiceCloudTasks:
244
+ """Test WorkerService Cloud Tasks integration."""
245
+
246
+ def test_should_use_cloud_tasks_default_false(self):
247
+ """Test that Cloud Tasks is disabled by default."""
248
+ from backend.services.worker_service import WorkerService, reset_worker_service
249
+ reset_worker_service()
250
+
251
+ with patch('backend.services.worker_service.os.getenv') as mock_getenv:
252
+ mock_getenv.side_effect = lambda k, d=None: {
253
+ 'PORT': '8000',
254
+ }.get(k, d)
255
+
256
+ with patch('backend.services.worker_service.get_settings') as mock_settings:
257
+ mock_settings.return_value.admin_tokens = None
258
+ mock_settings.return_value.google_cloud_project = "test-project"
259
+ mock_settings.return_value.enable_cloud_tasks = False
260
+ mock_settings.return_value.gcp_region = "us-central1"
261
+ mock_settings.return_value.use_cloud_run_jobs_for_video = False
262
+
263
+ service = WorkerService()
264
+ assert service._use_cloud_tasks is False
265
+
266
+ def test_should_use_cloud_tasks_enabled(self):
267
+ """Test that Cloud Tasks can be enabled via settings."""
268
+ from backend.services.worker_service import WorkerService, reset_worker_service
269
+ reset_worker_service()
270
+
271
+ with patch('backend.services.worker_service.os.getenv') as mock_getenv:
272
+ mock_getenv.side_effect = lambda k, d=None: {
273
+ 'PORT': '8000',
274
+ }.get(k, d)
275
+
276
+ with patch('backend.services.worker_service.get_settings') as mock_settings:
277
+ mock_settings.return_value.admin_tokens = None
278
+ mock_settings.return_value.google_cloud_project = "test-project"
279
+ mock_settings.return_value.enable_cloud_tasks = True
280
+ mock_settings.return_value.gcp_region = "us-central1"
281
+ mock_settings.return_value.use_cloud_run_jobs_for_video = False
282
+
283
+ service = WorkerService()
284
+ assert service._use_cloud_tasks is True
285
+
286
+ def test_worker_queues_mapping(self):
287
+ """Test that all worker types have queue mappings."""
288
+ from backend.services.worker_service import WORKER_QUEUES
289
+
290
+ # Verify all expected workers have queues
291
+ assert "audio" in WORKER_QUEUES
292
+ assert "lyrics" in WORKER_QUEUES
293
+ assert "screens" in WORKER_QUEUES
294
+ assert "render-video" in WORKER_QUEUES
295
+ assert "video" in WORKER_QUEUES
296
+
297
+ # Verify queue names are correct
298
+ assert WORKER_QUEUES["audio"] == "audio-worker-queue"
299
+ assert WORKER_QUEUES["lyrics"] == "lyrics-worker-queue"
300
+ assert WORKER_QUEUES["screens"] == "screens-worker-queue"
301
+ assert WORKER_QUEUES["render-video"] == "render-worker-queue"
302
+ assert WORKER_QUEUES["video"] == "video-worker-queue"
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_trigger_worker_uses_http_when_cloud_tasks_disabled(self):
306
+ """Test that trigger_worker uses HTTP when Cloud Tasks is disabled."""
307
+ from backend.services.worker_service import WorkerService, reset_worker_service
308
+ reset_worker_service()
309
+
310
+ with patch('backend.services.worker_service.os.getenv') as mock_getenv:
311
+ mock_getenv.side_effect = lambda k, d=None: {
312
+ 'PORT': '8000',
313
+ }.get(k, d)
314
+
315
+ with patch('backend.services.worker_service.get_settings') as mock_settings:
316
+ mock_settings.return_value.admin_tokens = "test-token"
317
+ mock_settings.return_value.google_cloud_project = "test-project"
318
+ mock_settings.return_value.enable_cloud_tasks = False
319
+ mock_settings.return_value.gcp_region = "us-central1"
320
+ mock_settings.return_value.use_cloud_run_jobs_for_video = False
321
+
322
+ with patch('backend.services.worker_service.httpx.AsyncClient') as mock_client_cls:
323
+ mock_client = AsyncMock()
324
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
325
+
326
+ mock_response = Mock()
327
+ mock_response.status_code = 200
328
+ mock_client.post.return_value = mock_response
329
+
330
+ service = WorkerService()
331
+ assert service._use_cloud_tasks is False
332
+
333
+ result = await service.trigger_worker("audio", "test-job-123")
334
+
335
+ # Verify HTTP was used
336
+ assert result is True
337
+ mock_client.post.assert_called_once()
338
+
339
+ @pytest.mark.asyncio
340
+ async def test_trigger_worker_uses_cloud_tasks_when_enabled(self):
341
+ """Test that trigger_worker uses Cloud Tasks when enabled."""
342
+ from backend.services.worker_service import WorkerService, reset_worker_service
343
+ reset_worker_service()
344
+
345
+ with patch('backend.services.worker_service.os.getenv') as mock_getenv:
346
+ mock_getenv.side_effect = lambda k, d=None: {
347
+ 'CLOUD_RUN_SERVICE_URL': 'https://api.example.com',
348
+ }.get(k, d)
349
+
350
+ with patch('backend.services.worker_service.get_settings') as mock_settings:
351
+ mock_settings.return_value.admin_tokens = "test-token"
352
+ mock_settings.return_value.google_cloud_project = "test-project"
353
+ mock_settings.return_value.enable_cloud_tasks = True
354
+ mock_settings.return_value.gcp_region = "us-central1"
355
+ mock_settings.return_value.use_cloud_run_jobs_for_video = False
356
+
357
+ # Mock Cloud Tasks client
358
+ mock_tasks_client = MagicMock()
359
+ mock_tasks_client.queue_path.return_value = "projects/test-project/locations/us-central1/queues/audio-worker-queue"
360
+ mock_task_response = MagicMock()
361
+ mock_task_response.name = "projects/test-project/locations/us-central1/queues/audio-worker-queue/tasks/abc123"
362
+ mock_tasks_client.create_task.return_value = mock_task_response
363
+
364
+ # Mock the google.cloud.tasks_v2 module which is imported inside the method
365
+ mock_tasks_module = MagicMock()
366
+ mock_tasks_module.CloudTasksClient.return_value = mock_tasks_client
367
+ mock_tasks_module.HttpMethod.POST = "POST"
368
+
369
+ with patch.dict('sys.modules', {'google.cloud.tasks_v2': mock_tasks_module}):
370
+ with patch.dict(sys.modules, {'google.cloud': MagicMock()}):
371
+ service = WorkerService()
372
+ service._tasks_client = mock_tasks_client # Inject mocked client
373
+
374
+ result = await service.trigger_worker("audio", "test-job-123")
375
+
376
+ # Verify Cloud Tasks was used
377
+ assert result is True
378
+ mock_tasks_client.create_task.assert_called_once()
379
+
380
+ # Verify task payload
381
+ call_args = mock_tasks_client.create_task.call_args
382
+ assert call_args is not None
383
+
384
+ @pytest.mark.asyncio
385
+ async def test_trigger_worker_returns_false_for_unknown_worker_type(self):
386
+ """Test that trigger_worker returns False for unknown worker types."""
387
+ from backend.services.worker_service import WorkerService, reset_worker_service
388
+ reset_worker_service()
389
+
390
+ with patch('backend.services.worker_service.os.getenv') as mock_getenv:
391
+ mock_getenv.side_effect = lambda k, d=None: {
392
+ 'CLOUD_RUN_SERVICE_URL': 'https://api.example.com',
393
+ }.get(k, d)
394
+
395
+ with patch('backend.services.worker_service.get_settings') as mock_settings:
396
+ mock_settings.return_value.admin_tokens = "test-token"
397
+ mock_settings.return_value.google_cloud_project = "test-project"
398
+ mock_settings.return_value.enable_cloud_tasks = True
399
+ mock_settings.return_value.gcp_region = "us-central1"
400
+ mock_settings.return_value.use_cloud_run_jobs_for_video = False
401
+
402
+ service = WorkerService()
403
+
404
+ result = await service.trigger_worker("unknown-worker", "test-job-123")
405
+
406
+ assert result is False
407
+
408
+ def test_reset_worker_service(self):
409
+ """Test that reset_worker_service resets the singleton."""
410
+ from backend.services.worker_service import get_worker_service, reset_worker_service
411
+
412
+ with patch('backend.services.worker_service.get_settings') as mock_settings:
413
+ mock_settings.return_value.admin_tokens = None
414
+ mock_settings.return_value.google_cloud_project = "test-project"
415
+ mock_settings.return_value.enable_cloud_tasks = False
416
+ mock_settings.return_value.gcp_region = "us-central1"
417
+ mock_settings.return_value.use_cloud_run_jobs_for_video = False
418
+
419
+ service1 = get_worker_service()
420
+ reset_worker_service()
421
+ service2 = get_worker_service()
422
+
423
+ # After reset, should be different instances
424
+ assert service1 is not service2
425
+
426
+
427
+ class TestFirestoreService:
428
+ """Test FirestoreService database operations."""
429
+
430
+ def test_create_job(self):
431
+ """Test creating a job document in Firestore."""
432
+ with patch('backend.services.firestore_service.firestore') as mock_firestore:
433
+ mock_client = MagicMock()
434
+ mock_firestore.Client.return_value = mock_client
435
+
436
+ mock_collection = MagicMock()
437
+ mock_client.collection.return_value = mock_collection
438
+
439
+ mock_doc_ref = MagicMock()
440
+ mock_collection.document.return_value = mock_doc_ref
441
+
442
+ firestore_service = FirestoreService()
443
+
444
+ job = Job(
445
+ job_id="test123",
446
+ status=JobStatus.PENDING,
447
+ created_at=datetime.now(UTC),
448
+ updated_at=datetime.now(UTC)
449
+ )
450
+
451
+ firestore_service.create_job(job) # Returns None
452
+
453
+ # Verify document was set
454
+ mock_doc_ref.set.assert_called_once()
455
+
456
+ def test_get_job(self):
457
+ """Test fetching a job from Firestore."""
458
+ with patch('backend.services.firestore_service.firestore') as mock_firestore:
459
+ mock_client = MagicMock()
460
+ mock_firestore.Client.return_value = mock_client
461
+
462
+ mock_collection = MagicMock()
463
+ mock_client.collection.return_value = mock_collection
464
+
465
+ mock_doc_ref = MagicMock()
466
+ mock_collection.document.return_value = mock_doc_ref
467
+
468
+ mock_doc = MagicMock()
469
+ mock_doc.exists = True
470
+ mock_doc.to_dict.return_value = {
471
+ 'job_id': 'test123',
472
+ 'status': 'pending',
473
+ 'progress': 0,
474
+ 'created_at': datetime.now(UTC).isoformat(),
475
+ 'updated_at': datetime.now(UTC).isoformat()
476
+ }
477
+ mock_doc_ref.get.return_value = mock_doc
478
+
479
+ firestore_service = FirestoreService()
480
+
481
+ job = firestore_service.get_job("test123")
482
+
483
+ # Verify document was fetched
484
+ mock_doc_ref.get.assert_called_once()
485
+ assert job is not None
486
+ assert job.job_id == "test123"
487
+
488
+ def test_get_nonexistent_job(self):
489
+ """Test fetching a job that doesn't exist."""
490
+ with patch('backend.services.firestore_service.firestore') as mock_firestore:
491
+ mock_client = MagicMock()
492
+ mock_firestore.Client.return_value = mock_client
493
+
494
+ mock_collection = MagicMock()
495
+ mock_client.collection.return_value = mock_collection
496
+
497
+ mock_doc_ref = MagicMock()
498
+ mock_collection.document.return_value = mock_doc_ref
499
+
500
+ mock_doc = MagicMock()
501
+ mock_doc.exists = False
502
+ mock_doc_ref.get.return_value = mock_doc
503
+
504
+ firestore_service = FirestoreService()
505
+
506
+ job = firestore_service.get_job("nonexistent")
507
+
508
+ assert job is None
509
+
510
+ def test_update_job(self):
511
+ """Test updating a job in Firestore."""
512
+ with patch('backend.services.firestore_service.firestore') as mock_firestore:
513
+ mock_client = MagicMock()
514
+ mock_firestore.Client.return_value = mock_client
515
+
516
+ mock_collection = MagicMock()
517
+ mock_client.collection.return_value = mock_collection
518
+
519
+ mock_doc_ref = MagicMock()
520
+ mock_collection.document.return_value = mock_doc_ref
521
+
522
+ firestore_service = FirestoreService()
523
+
524
+ updates = {
525
+ 'status': JobStatus.SEPARATING_STAGE1,
526
+ 'progress': 25
527
+ }
528
+
529
+ firestore_service.update_job("test123", updates)
530
+
531
+ # Verify update was called
532
+ mock_doc_ref.update.assert_called_once()
533
+
534
+ def test_delete_job(self):
535
+ """Test deleting a job from Firestore."""
536
+ with patch('backend.services.firestore_service.firestore') as mock_firestore:
537
+ mock_client = MagicMock()
538
+ mock_firestore.Client.return_value = mock_client
539
+
540
+ mock_collection = MagicMock()
541
+ mock_client.collection.return_value = mock_collection
542
+
543
+ mock_doc_ref = MagicMock()
544
+ mock_collection.document.return_value = mock_doc_ref
545
+
546
+ firestore_service = FirestoreService()
547
+
548
+ firestore_service.delete_job("test123")
549
+
550
+ # Verify delete was called
551
+ mock_doc_ref.delete.assert_called_once()
552
+
553
+
554
+ if __name__ == "__main__":
555
+ pytest.main([__file__, "-v"])
556
+
@@ -0,0 +1,112 @@
1
+ """
2
+ Extended unit tests for services.
3
+
4
+ These tests provide additional coverage for service layer code.
5
+ """
6
+ import pytest
7
+ from datetime import datetime, UTC
8
+ from unittest.mock import MagicMock, AsyncMock, patch
9
+
10
+ from backend.models.job import Job, JobStatus
11
+
12
+
13
+ class TestJobManagerExtended:
14
+ """Extended tests for JobManager.
15
+
16
+ Note: These tests verify the module structure and basic behavior.
17
+ Full JobManager testing is in test_job_manager.py.
18
+ """
19
+
20
+ def test_job_manager_module_imports(self):
21
+ """Test JobManager module can be imported."""
22
+ from backend.services import job_manager
23
+ assert hasattr(job_manager, 'JobManager')
24
+
25
+ def test_state_transitions_defined(self):
26
+ """Test STATE_TRANSITIONS dict is defined."""
27
+ from backend.models.job import STATE_TRANSITIONS
28
+ assert STATE_TRANSITIONS is not None
29
+ assert len(STATE_TRANSITIONS) > 0
30
+
31
+
32
+ class TestStorageServiceExtended:
33
+ """Extended tests for StorageService."""
34
+
35
+ def test_storage_service_initialization(self):
36
+ """Test StorageService can be initialized."""
37
+ with patch('backend.services.storage_service.storage'):
38
+ from backend.services.storage_service import StorageService
39
+ service = StorageService()
40
+ assert service is not None
41
+
42
+ def test_storage_service_bucket_name(self):
43
+ """Test StorageService uses configured bucket."""
44
+ mock_client = MagicMock()
45
+ mock_bucket = MagicMock()
46
+ mock_client.bucket.return_value = mock_bucket
47
+
48
+ with patch('backend.services.storage_service.storage.Client', return_value=mock_client), \
49
+ patch.dict('os.environ', {'GCS_BUCKET_NAME': 'test-bucket'}):
50
+ from backend.services.storage_service import StorageService
51
+ service = StorageService()
52
+ # Service should use the configured bucket
53
+
54
+
55
+ class TestAuthServiceExtended:
56
+ """Extended tests for AuthService.
57
+
58
+ Note: Full auth service testing is in test_services.py.
59
+ """
60
+
61
+ def test_auth_service_module_imports(self):
62
+ """Test AuthService module can be imported."""
63
+ from backend.services import auth_service
64
+ assert hasattr(auth_service, 'AuthService')
65
+
66
+
67
+ class TestWorkerServiceExtended:
68
+ """Extended tests for WorkerService."""
69
+
70
+ def test_worker_service_initialization(self):
71
+ """Test WorkerService can be initialized."""
72
+ from backend.services.worker_service import WorkerService
73
+ service = WorkerService()
74
+ assert service is not None
75
+
76
+ def test_worker_service_get_base_url(self):
77
+ """Test WorkerService constructs correct base URL."""
78
+ from backend.services.worker_service import WorkerService
79
+ service = WorkerService()
80
+
81
+ url = service._get_base_url()
82
+ assert 'http' in url
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_worker_service_trigger_with_mock(self):
86
+ """Test triggering a worker with mocked HTTP."""
87
+ from backend.services.worker_service import WorkerService
88
+
89
+ service = WorkerService()
90
+
91
+ # Mock the HTTP client
92
+ with patch('backend.services.worker_service.httpx.AsyncClient') as mock_client_cls:
93
+ mock_client = MagicMock()
94
+ mock_response = MagicMock()
95
+ mock_response.status_code = 200
96
+ mock_client.post = AsyncMock(return_value=mock_response)
97
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
98
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
99
+
100
+ # The trigger methods should work with mocked HTTP
101
+
102
+
103
+ class TestFirestoreServiceExtended:
104
+ """Extended tests for FirestoreService."""
105
+
106
+ def test_firestore_service_initialization(self):
107
+ """Test FirestoreService can be initialized."""
108
+ with patch('backend.services.firestore_service.firestore'):
109
+ from backend.services.firestore_service import FirestoreService
110
+ service = FirestoreService()
111
+ assert service is not None
112
+