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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +502 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +109 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +356 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +283 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/utils/test_data.py +27 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +535 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {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
|
+
|