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,460 @@
1
+ """
2
+ Integration tests for the karaoke generation backend API.
3
+
4
+ These tests verify the backend works end-to-end with real Cloud Run deployment.
5
+ These tests require a deployed backend and are marked to skip unless explicitly enabled.
6
+
7
+ Run with: pytest backend/tests/test_api_integration.py -m integration
8
+ """
9
+ import pytest
10
+ import requests
11
+ import subprocess
12
+ import time
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Optional, Dict, Any
16
+ import os
17
+
18
+
19
+ # Skip all tests unless explicitly running integration tests
20
+ pytestmark = pytest.mark.skipif(
21
+ os.environ.get("RUN_INTEGRATION_TESTS") != "true",
22
+ reason="Integration tests require deployed backend. Set RUN_INTEGRATION_TESTS=true to run."
23
+ )
24
+
25
+ # Configuration
26
+ SERVICE_URL = "https://karaoke-backend-718638054799.us-central1.run.app"
27
+ TEST_YOUTUBE_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" # Rick Astley - Never Gonna Give You Up
28
+ DEFAULT_TIMEOUT = 30 # seconds
29
+
30
+
31
+ def api_get(url: str, headers: Optional[Dict[str, str]] = None, **kwargs) -> requests.Response:
32
+ """Make a GET request with default timeout."""
33
+ return requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT, **kwargs)
34
+
35
+
36
+ def api_post(url: str, headers: Optional[Dict[str, str]] = None, **kwargs) -> requests.Response:
37
+ """Make a POST request with default timeout."""
38
+ return requests.post(url, headers=headers, timeout=DEFAULT_TIMEOUT, **kwargs)
39
+
40
+
41
+ def api_delete(url: str, headers: Optional[Dict[str, str]] = None, **kwargs) -> requests.Response:
42
+ """Make a DELETE request with default timeout."""
43
+ return requests.delete(url, headers=headers, timeout=DEFAULT_TIMEOUT, **kwargs)
44
+
45
+
46
+ def get_auth_token() -> str:
47
+ """Get authentication token for Cloud Run."""
48
+ result = subprocess.run(
49
+ ["gcloud", "auth", "print-identity-token"],
50
+ capture_output=True,
51
+ text=True,
52
+ check=True
53
+ )
54
+ return result.stdout.strip()
55
+
56
+
57
+ @pytest.fixture
58
+ def auth_headers():
59
+ """Get authentication headers for requests."""
60
+ token = get_auth_token()
61
+ return {
62
+ "Authorization": f"Bearer {token}",
63
+ "Content-Type": "application/json"
64
+ }
65
+
66
+
67
+ class TestHealthEndpoint:
68
+ """Test health check endpoint."""
69
+
70
+ def test_health_check(self, auth_headers):
71
+ """Test that health endpoint returns 200 OK."""
72
+ response = api_get(f"{SERVICE_URL}/api/health", headers=auth_headers)
73
+ assert response.status_code == 200
74
+
75
+ data = response.json()
76
+ assert data["status"] == "healthy"
77
+ assert data["service"] == "karaoke-gen-backend"
78
+
79
+ def test_health_check_without_auth(self):
80
+ """Test that health endpoint requires authentication."""
81
+ response = api_get(f"{SERVICE_URL}/api/health")
82
+ assert response.status_code == 403
83
+
84
+
85
+ class TestRootEndpoint:
86
+ """Test root endpoint."""
87
+
88
+ def test_root_endpoint(self, auth_headers):
89
+ """Test root endpoint returns service info."""
90
+ response = api_get(SERVICE_URL, headers=auth_headers)
91
+ assert response.status_code == 200
92
+
93
+ data = response.json()
94
+ assert data["service"] == "karaoke-gen-backend"
95
+ assert data["status"] == "running"
96
+ assert "version" in data
97
+
98
+
99
+ class TestJobSubmission:
100
+ """Test job submission workflows."""
101
+
102
+ def test_submit_job_with_youtube_url(self, auth_headers):
103
+ """Test submitting a job with a YouTube URL."""
104
+ payload = {"url": TEST_YOUTUBE_URL}
105
+ response = api_post(
106
+ f"{SERVICE_URL}/api/jobs",
107
+ headers=auth_headers,
108
+ json=payload
109
+ )
110
+
111
+ assert response.status_code == 200
112
+ data = response.json()
113
+
114
+ assert data["status"] == "success"
115
+ assert "job_id" in data
116
+ assert len(data["job_id"]) > 0
117
+ assert "message" in data
118
+
119
+ # Store job_id for cleanup
120
+ return data["job_id"]
121
+
122
+ def test_submit_job_with_invalid_url(self, auth_headers):
123
+ """Test that invalid URLs are rejected."""
124
+ payload = {"url": "not-a-url"}
125
+ response = api_post(
126
+ f"{SERVICE_URL}/api/jobs",
127
+ headers=auth_headers,
128
+ json=payload
129
+ )
130
+
131
+ assert response.status_code == 422 # Validation error
132
+
133
+ def test_submit_job_without_url(self, auth_headers):
134
+ """Test that missing URL is rejected."""
135
+ payload = {}
136
+ response = api_post(
137
+ f"{SERVICE_URL}/api/jobs",
138
+ headers=auth_headers,
139
+ json=payload
140
+ )
141
+
142
+ assert response.status_code == 422 # Validation error
143
+
144
+
145
+ class TestJobRetrieval:
146
+ """Test job retrieval and status checking."""
147
+
148
+ @pytest.fixture
149
+ def test_job_id(self, auth_headers):
150
+ """Create a test job for retrieval tests."""
151
+ payload = {"url": TEST_YOUTUBE_URL}
152
+ response = api_post(
153
+ f"{SERVICE_URL}/api/jobs",
154
+ headers=auth_headers,
155
+ json=payload
156
+ )
157
+ assert response.status_code == 200
158
+ job_id = response.json()["job_id"]
159
+
160
+ yield job_id
161
+
162
+ # Cleanup
163
+ api_delete(
164
+ f"{SERVICE_URL}/api/jobs/{job_id}",
165
+ headers=auth_headers
166
+ )
167
+
168
+ def test_get_job_status(self, auth_headers, test_job_id):
169
+ """Test retrieving job status."""
170
+ response = api_get(
171
+ f"{SERVICE_URL}/api/jobs/{test_job_id}",
172
+ headers=auth_headers
173
+ )
174
+
175
+ assert response.status_code == 200
176
+ data = response.json()
177
+
178
+ assert data["job_id"] == test_job_id
179
+ assert "status" in data
180
+ # Updated to use current JobStatus enum values
181
+ assert data["status"] in ["pending", "downloading", "separating_stage1",
182
+ "separating_stage2", "audio_complete",
183
+ "transcribing", "correcting", "lyrics_complete",
184
+ "generating_screens", "applying_padding",
185
+ "awaiting_review", "in_review", "review_complete",
186
+ "rendering_video", "awaiting_instrumental_selection",
187
+ "instrumental_selected", "generating_video",
188
+ "encoding", "packaging", "uploading", "notifying",
189
+ "complete", "failed", "cancelled"]
190
+ assert "progress" in data
191
+ assert "created_at" in data
192
+ assert "updated_at" in data
193
+ assert "timeline" in data
194
+
195
+ def test_get_nonexistent_job(self, auth_headers):
196
+ """Test that requesting nonexistent job returns 404."""
197
+ fake_job_id = "nonexistent-job-id"
198
+ response = api_get(
199
+ f"{SERVICE_URL}/api/jobs/{fake_job_id}",
200
+ headers=auth_headers
201
+ )
202
+
203
+ assert response.status_code == 404
204
+
205
+ def test_list_jobs(self, auth_headers, test_job_id):
206
+ """Test listing all jobs."""
207
+ response = api_get(
208
+ f"{SERVICE_URL}/api/jobs",
209
+ headers=auth_headers
210
+ )
211
+
212
+ assert response.status_code == 200
213
+ data = response.json()
214
+
215
+ assert isinstance(data, list)
216
+ # Our test job should be in the list
217
+ job_ids = [job["job_id"] for job in data]
218
+ assert test_job_id in job_ids
219
+
220
+ def test_list_jobs_with_status_filter(self, auth_headers):
221
+ """Test filtering jobs by status."""
222
+ response = api_get(
223
+ f"{SERVICE_URL}/api/jobs?status=pending",
224
+ headers=auth_headers
225
+ )
226
+
227
+ assert response.status_code == 200
228
+ data = response.json()
229
+
230
+ assert isinstance(data, list)
231
+ # All returned jobs should be pending
232
+ for job in data:
233
+ assert job["status"] == "pending"
234
+
235
+ def test_list_jobs_with_limit(self, auth_headers):
236
+ """Test limiting number of returned jobs."""
237
+ response = api_get(
238
+ f"{SERVICE_URL}/api/jobs?limit=5",
239
+ headers=auth_headers
240
+ )
241
+
242
+ assert response.status_code == 200
243
+ data = response.json()
244
+
245
+ assert isinstance(data, list)
246
+ assert len(data) <= 5
247
+
248
+
249
+ class TestJobDeletion:
250
+ """Test job deletion."""
251
+
252
+ def test_delete_job(self, auth_headers):
253
+ """Test deleting a job."""
254
+ # Create a job
255
+ payload = {"url": TEST_YOUTUBE_URL}
256
+ response = api_post(
257
+ f"{SERVICE_URL}/api/jobs",
258
+ headers=auth_headers,
259
+ json=payload
260
+ )
261
+ assert response.status_code == 200
262
+ job_id = response.json()["job_id"]
263
+
264
+ # Delete the job
265
+ response = api_delete(
266
+ f"{SERVICE_URL}/api/jobs/{job_id}",
267
+ headers=auth_headers
268
+ )
269
+
270
+ assert response.status_code == 200
271
+ data = response.json()
272
+ assert data["status"] == "success"
273
+
274
+ # Verify job is deleted
275
+ response = api_get(
276
+ f"{SERVICE_URL}/api/jobs/{job_id}",
277
+ headers=auth_headers
278
+ )
279
+ assert response.status_code == 404
280
+
281
+ def test_delete_job_without_files(self, auth_headers):
282
+ """Test deleting a job without deleting files."""
283
+ # Create a job
284
+ payload = {"url": TEST_YOUTUBE_URL}
285
+ response = api_post(
286
+ f"{SERVICE_URL}/api/jobs",
287
+ headers=auth_headers,
288
+ json=payload
289
+ )
290
+ assert response.status_code == 200
291
+ job_id = response.json()["job_id"]
292
+
293
+ # Delete job but keep files
294
+ response = api_delete(
295
+ f"{SERVICE_URL}/api/jobs/{job_id}?delete_files=false",
296
+ headers=auth_headers
297
+ )
298
+
299
+ assert response.status_code == 200
300
+ data = response.json()
301
+ assert data["status"] == "success"
302
+
303
+ def test_delete_nonexistent_job(self, auth_headers):
304
+ """Test deleting nonexistent job returns 404."""
305
+ fake_job_id = "nonexistent-job-id"
306
+ response = api_delete(
307
+ f"{SERVICE_URL}/api/jobs/{fake_job_id}",
308
+ headers=auth_headers
309
+ )
310
+
311
+ assert response.status_code == 404
312
+
313
+
314
+ class TestFileUpload:
315
+ """Test file upload endpoint."""
316
+
317
+ def test_upload_audio_file(self, auth_headers, tmp_path):
318
+ """Test uploading an audio file."""
319
+ # Create a small test file
320
+ test_file = tmp_path / "test_audio.mp3"
321
+ test_file.write_bytes(b"fake audio content for testing")
322
+
323
+ # Upload file
324
+ with open(test_file, "rb") as f:
325
+ files = {"file": ("test_audio.mp3", f, "audio/mpeg")}
326
+ data = {
327
+ "artist": "Test Artist",
328
+ "title": "Test Song"
329
+ }
330
+
331
+ # Remove Content-Type header for multipart/form-data
332
+ headers = {
333
+ "Authorization": auth_headers["Authorization"]
334
+ }
335
+
336
+ response = api_post(
337
+ f"{SERVICE_URL}/api/jobs/upload",
338
+ headers=headers,
339
+ files=files,
340
+ data=data
341
+ )
342
+
343
+ assert response.status_code == 200
344
+ result = response.json()
345
+
346
+ assert result["status"] == "success"
347
+ assert "job_id" in result
348
+
349
+ # Cleanup
350
+ api_delete(
351
+ f"{SERVICE_URL}/api/jobs/{result['job_id']}",
352
+ headers=auth_headers
353
+ )
354
+
355
+ def test_upload_invalid_file_type(self, auth_headers, tmp_path):
356
+ """Test that invalid file types are rejected."""
357
+ # Create a test file with invalid extension
358
+ test_file = tmp_path / "test.txt"
359
+ test_file.write_bytes(b"not an audio file")
360
+
361
+ with open(test_file, "rb") as f:
362
+ files = {"file": ("test.txt", f, "text/plain")}
363
+ data = {
364
+ "artist": "Test Artist",
365
+ "title": "Test Song"
366
+ }
367
+
368
+ headers = {
369
+ "Authorization": auth_headers["Authorization"]
370
+ }
371
+
372
+ response = api_post(
373
+ f"{SERVICE_URL}/api/jobs/upload",
374
+ headers=headers,
375
+ files=files,
376
+ data=data
377
+ )
378
+
379
+ assert response.status_code == 400
380
+
381
+ def test_upload_without_metadata(self, auth_headers, tmp_path):
382
+ """Test that uploads without artist/title are rejected."""
383
+ test_file = tmp_path / "test_audio.mp3"
384
+ test_file.write_bytes(b"fake audio content")
385
+
386
+ with open(test_file, "rb") as f:
387
+ files = {"file": ("test_audio.mp3", f, "audio/mpeg")}
388
+
389
+ headers = {
390
+ "Authorization": auth_headers["Authorization"]
391
+ }
392
+
393
+ response = api_post(
394
+ f"{SERVICE_URL}/api/jobs/upload",
395
+ headers=headers,
396
+ files=files
397
+ )
398
+
399
+ assert response.status_code == 422 # Validation error
400
+
401
+
402
+ class TestJobProcessing:
403
+ """Test end-to-end job processing (long-running)."""
404
+
405
+ @pytest.mark.slow
406
+ @pytest.mark.integration
407
+ def test_complete_job_workflow(self, auth_headers):
408
+ """
409
+ Test complete job workflow from submission to completion.
410
+
411
+ This test is slow and requires actual processing.
412
+ Run with: pytest -m slow
413
+ """
414
+ # Submit job
415
+ payload = {"url": TEST_YOUTUBE_URL}
416
+ response = api_post(
417
+ f"{SERVICE_URL}/api/jobs",
418
+ headers=auth_headers,
419
+ json=payload
420
+ )
421
+ assert response.status_code == 200
422
+ job_id = response.json()["job_id"]
423
+
424
+ # Poll for completion (with timeout)
425
+ timeout = 600 # 10 minutes
426
+ start_time = time.time()
427
+
428
+ while time.time() - start_time < timeout:
429
+ response = api_get(
430
+ f"{SERVICE_URL}/api/jobs/{job_id}",
431
+ headers=auth_headers
432
+ )
433
+ assert response.status_code == 200
434
+
435
+ job = response.json()
436
+ status = job["status"]
437
+
438
+ if status == "complete":
439
+ # Verify outputs exist
440
+ assert "download_urls" in job
441
+ assert len(job["download_urls"]) > 0
442
+ break
443
+ elif status == "failed":
444
+ pytest.fail(f"Job failed: {job.get('error_message')}")
445
+
446
+ # Wait before next poll
447
+ time.sleep(10)
448
+ else:
449
+ pytest.fail(f"Job did not complete within {timeout} seconds")
450
+
451
+ # Cleanup
452
+ api_delete(
453
+ f"{SERVICE_URL}/api/jobs/{job_id}",
454
+ headers=auth_headers
455
+ )
456
+
457
+
458
+ if __name__ == "__main__":
459
+ pytest.main([__file__, "-v", "--tb=short"])
460
+
@@ -0,0 +1,93 @@
1
+ """
2
+ Unit tests for API routes.
3
+
4
+ These tests use FastAPI TestClient with mocked services to test
5
+ route logic without hitting real cloud services.
6
+ """
7
+ import pytest
8
+ import json
9
+ from datetime import datetime, UTC
10
+ from unittest.mock import MagicMock, AsyncMock, patch
11
+ from fastapi.testclient import TestClient
12
+ from io import BytesIO
13
+
14
+ from backend.models.job import Job, JobStatus
15
+
16
+
17
+ class TestHealthRoutes:
18
+ """Tests for health.py routes."""
19
+
20
+ @pytest.fixture
21
+ def client(self):
22
+ """Create test client with mocked dependencies."""
23
+ mock_creds = MagicMock()
24
+ mock_creds.universe_domain = 'googleapis.com'
25
+ with patch('backend.services.firestore_service.firestore'), \
26
+ patch('backend.services.storage_service.storage'), \
27
+ patch('google.auth.default', return_value=(mock_creds, 'test-project')):
28
+ from backend.main import app
29
+ return TestClient(app)
30
+
31
+ def test_health_endpoint_returns_200(self, client, auth_headers):
32
+ """Test /api/health returns 200 OK."""
33
+ response = client.get("/api/health", )
34
+ assert response.status_code == 200
35
+
36
+ def test_health_endpoint_returns_healthy_status(self, client, auth_headers):
37
+ """Test health endpoint returns healthy status."""
38
+ response = client.get("/api/health", )
39
+ data = response.json()
40
+ assert data["status"] == "healthy"
41
+
42
+ def test_root_endpoint_returns_200(self, client, auth_headers):
43
+ """Test root endpoint returns 200."""
44
+ response = client.get("/", )
45
+ assert response.status_code == 200
46
+
47
+
48
+ class TestJobRoutes:
49
+ """Tests for jobs.py routes.
50
+
51
+ Note: These tests verify the route module structure.
52
+ Full integration tests are in test_api_integration.py.
53
+ """
54
+
55
+ def test_jobs_router_exists(self):
56
+ """Test jobs router can be imported."""
57
+ from backend.api.routes import jobs
58
+ assert hasattr(jobs, 'router')
59
+
60
+ def test_jobs_router_has_expected_endpoints(self):
61
+ """Test jobs router defines expected endpoints."""
62
+ from backend.api.routes.jobs import router
63
+ routes = [route.path for route in router.routes]
64
+ assert '/jobs' in routes or any('/jobs' in r for r in routes)
65
+
66
+
67
+ class TestInternalRoutes:
68
+ """Tests for internal.py routes.
69
+
70
+ Note: These tests are minimal because the internal routes
71
+ trigger actual worker processing which requires complex mocking.
72
+ The actual worker logic is tested in test_workers.py.
73
+ """
74
+
75
+ def test_internal_endpoint_structure(self):
76
+ """Test internal route module has expected endpoints."""
77
+ from backend.api.routes import internal
78
+ # Just verify the module can be imported
79
+ assert hasattr(internal, 'router')
80
+
81
+
82
+ class TestFileUploadRoutes:
83
+ """Tests for file_upload.py routes.
84
+
85
+ Note: These tests verify the route module structure.
86
+ Full integration tests are in test_api_integration.py.
87
+ """
88
+
89
+ def test_file_upload_router_exists(self):
90
+ """Test file upload router can be imported."""
91
+ from backend.api.routes import file_upload
92
+ assert hasattr(file_upload, 'router')
93
+