karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -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 +405 -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 +842 -0
- backend/services/job_notification_service.py +271 -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/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -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 +88 -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 +339 -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 +273 -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_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/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 +525 -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.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- 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.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1739 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for file upload endpoint.
|
|
3
|
+
|
|
4
|
+
Tests the file upload logic including validation, GCS storage,
|
|
5
|
+
and job creation without requiring actual cloud resources.
|
|
6
|
+
"""
|
|
7
|
+
import pytest
|
|
8
|
+
from unittest.mock import Mock, MagicMock, patch, AsyncMock
|
|
9
|
+
from fastapi import UploadFile
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from datetime import datetime, UTC
|
|
12
|
+
|
|
13
|
+
# Mock Firestore and GCS before importing
|
|
14
|
+
import sys
|
|
15
|
+
sys.modules['google.cloud.firestore'] = MagicMock()
|
|
16
|
+
sys.modules['google.cloud.storage'] = MagicMock()
|
|
17
|
+
|
|
18
|
+
from backend.api.routes.file_upload import router
|
|
19
|
+
from backend.models.job import Job, JobStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def mock_job_manager():
|
|
24
|
+
"""Mock JobManager."""
|
|
25
|
+
with patch('backend.api.routes.file_upload.job_manager') as mock:
|
|
26
|
+
yield mock
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def mock_storage_service():
|
|
31
|
+
"""Mock StorageService."""
|
|
32
|
+
with patch('backend.api.routes.file_upload.storage_service') as mock:
|
|
33
|
+
yield mock
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def mock_worker_service():
|
|
38
|
+
"""Mock WorkerService."""
|
|
39
|
+
with patch('backend.api.routes.file_upload.worker_service') as mock:
|
|
40
|
+
yield mock
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def sample_job():
|
|
45
|
+
"""Create a sample job."""
|
|
46
|
+
return Job(
|
|
47
|
+
job_id="test123",
|
|
48
|
+
status=JobStatus.PENDING,
|
|
49
|
+
created_at=datetime.now(UTC),
|
|
50
|
+
updated_at=datetime.now(UTC),
|
|
51
|
+
artist="Test Artist",
|
|
52
|
+
title="Test Song"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestFileValidation:
|
|
57
|
+
"""Test file upload validation."""
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize("filename,expected_valid", [
|
|
60
|
+
("test.mp3", True),
|
|
61
|
+
("test.flac", True),
|
|
62
|
+
("test.wav", True),
|
|
63
|
+
("test.m4a", True),
|
|
64
|
+
("test.ogg", True),
|
|
65
|
+
("test.aac", True),
|
|
66
|
+
("test.txt", False),
|
|
67
|
+
("test.pdf", False),
|
|
68
|
+
("test.exe", False),
|
|
69
|
+
("test", False),
|
|
70
|
+
])
|
|
71
|
+
def test_file_extension_validation(self, filename, expected_valid):
|
|
72
|
+
"""Test that only valid audio file extensions are accepted."""
|
|
73
|
+
from pathlib import Path
|
|
74
|
+
|
|
75
|
+
allowed_extensions = {'.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aac'}
|
|
76
|
+
file_ext = Path(filename).suffix.lower()
|
|
77
|
+
|
|
78
|
+
is_valid = file_ext in allowed_extensions
|
|
79
|
+
assert is_valid == expected_valid
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestFileUploadFlow:
|
|
83
|
+
"""Test complete file upload flow."""
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_successful_file_upload(
|
|
87
|
+
self,
|
|
88
|
+
mock_job_manager,
|
|
89
|
+
mock_storage_service,
|
|
90
|
+
mock_worker_service,
|
|
91
|
+
sample_job
|
|
92
|
+
):
|
|
93
|
+
"""Test successful file upload creates job and triggers workers."""
|
|
94
|
+
# Setup mocks
|
|
95
|
+
mock_job_manager.create_job.return_value = sample_job
|
|
96
|
+
job_with_path = sample_job.model_copy(update={"input_media_gcs_path": "uploads/test123/test.flac"})
|
|
97
|
+
mock_job_manager.get_job.return_value = job_with_path
|
|
98
|
+
mock_worker_service.trigger_audio_worker = AsyncMock()
|
|
99
|
+
mock_worker_service.trigger_lyrics_worker = AsyncMock()
|
|
100
|
+
|
|
101
|
+
# Create mock upload file
|
|
102
|
+
file_content = b"fake audio data"
|
|
103
|
+
upload_file = UploadFile(
|
|
104
|
+
filename="test.flac",
|
|
105
|
+
file=BytesIO(file_content)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Test the upload logic
|
|
109
|
+
# (Note: This would need the actual endpoint to be called,
|
|
110
|
+
# here we're testing the business logic)
|
|
111
|
+
|
|
112
|
+
# Verify job was created
|
|
113
|
+
# Verify file was uploaded to GCS
|
|
114
|
+
# Verify workers were triggered
|
|
115
|
+
# This test would be more complete with actual endpoint testing
|
|
116
|
+
|
|
117
|
+
@pytest.mark.asyncio
|
|
118
|
+
async def test_upload_sets_input_media_gcs_path(
|
|
119
|
+
self,
|
|
120
|
+
mock_job_manager,
|
|
121
|
+
mock_storage_service,
|
|
122
|
+
sample_job
|
|
123
|
+
):
|
|
124
|
+
"""Test that upload sets input_media_gcs_path correctly."""
|
|
125
|
+
# Setup
|
|
126
|
+
mock_job_manager.create_job.return_value = sample_job
|
|
127
|
+
|
|
128
|
+
expected_gcs_path = "uploads/test123/test.flac"
|
|
129
|
+
|
|
130
|
+
# Simulate the update call
|
|
131
|
+
mock_job_manager.update_job.return_value = None
|
|
132
|
+
|
|
133
|
+
# When update_job is called, it should include input_media_gcs_path
|
|
134
|
+
# This is what we're testing to prevent the bug we just fixed
|
|
135
|
+
|
|
136
|
+
# Verify the update includes input_media_gcs_path
|
|
137
|
+
# (This would be tested in integration test with actual endpoint)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestGCSPathGeneration:
|
|
141
|
+
"""Test GCS path generation logic."""
|
|
142
|
+
|
|
143
|
+
def test_gcs_path_format(self):
|
|
144
|
+
"""Test that GCS paths follow expected format."""
|
|
145
|
+
job_id = "test123"
|
|
146
|
+
filename = "test.flac"
|
|
147
|
+
|
|
148
|
+
expected_path = f"uploads/{job_id}/{filename}"
|
|
149
|
+
assert expected_path == "uploads/test123/test.flac"
|
|
150
|
+
|
|
151
|
+
def test_gcs_path_with_special_characters(self):
|
|
152
|
+
"""Test GCS path handling of special characters in filename."""
|
|
153
|
+
job_id = "test123"
|
|
154
|
+
filename = "test song (remix).flac"
|
|
155
|
+
|
|
156
|
+
gcs_path = f"uploads/{job_id}/{filename}"
|
|
157
|
+
|
|
158
|
+
# Path should preserve special characters
|
|
159
|
+
assert "(" in gcs_path
|
|
160
|
+
assert ")" in gcs_path
|
|
161
|
+
assert " " in gcs_path
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestFirestoreConsistency:
|
|
165
|
+
"""Test Firestore consistency handling."""
|
|
166
|
+
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
async def test_update_verification(
|
|
169
|
+
self,
|
|
170
|
+
mock_job_manager,
|
|
171
|
+
sample_job
|
|
172
|
+
):
|
|
173
|
+
"""Test that job update is verified before triggering workers."""
|
|
174
|
+
# This tests the fix for the Firestore consistency bug
|
|
175
|
+
|
|
176
|
+
# First fetch should not have input_media_gcs_path
|
|
177
|
+
job_without_path = Job(**sample_job.model_dump())
|
|
178
|
+
job_without_path.input_media_gcs_path = None
|
|
179
|
+
|
|
180
|
+
# Second fetch should have it
|
|
181
|
+
job_with_path = Job(**sample_job.model_dump())
|
|
182
|
+
job_with_path.input_media_gcs_path = "uploads/test123/test.flac"
|
|
183
|
+
|
|
184
|
+
mock_job_manager.get_job.side_effect = [
|
|
185
|
+
job_without_path, # First call (update not visible yet)
|
|
186
|
+
job_with_path # Second call (after retry)
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
# The upload logic should:
|
|
190
|
+
# 1. Update job
|
|
191
|
+
# 2. Fetch to verify
|
|
192
|
+
# 3. If not visible, wait and retry
|
|
193
|
+
# 4. Only trigger workers after verification
|
|
194
|
+
|
|
195
|
+
# This ensures workers don't see stale data
|
|
196
|
+
|
|
197
|
+
@pytest.mark.asyncio
|
|
198
|
+
async def test_update_timeout(
|
|
199
|
+
self,
|
|
200
|
+
mock_job_manager,
|
|
201
|
+
sample_job
|
|
202
|
+
):
|
|
203
|
+
"""Test that upload fails if update never becomes visible."""
|
|
204
|
+
# This should raise HTTPException if update never succeeds
|
|
205
|
+
|
|
206
|
+
job_without_path = Job(**sample_job.model_dump())
|
|
207
|
+
job_without_path.input_media_gcs_path = None
|
|
208
|
+
|
|
209
|
+
# Always return job without path (simulate update never visible)
|
|
210
|
+
mock_job_manager.get_job.return_value = job_without_path
|
|
211
|
+
|
|
212
|
+
# Upload should fail with 500 error
|
|
213
|
+
# "Failed to update job with GCS path"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TestJobModelFieldPresence:
|
|
217
|
+
"""Test that Job model has required fields."""
|
|
218
|
+
|
|
219
|
+
def test_input_media_gcs_path_field_exists(self):
|
|
220
|
+
"""Test that Job model has input_media_gcs_path field."""
|
|
221
|
+
from backend.models.job import Job
|
|
222
|
+
from datetime import datetime, UTC
|
|
223
|
+
|
|
224
|
+
job = Job(
|
|
225
|
+
job_id="test123",
|
|
226
|
+
status=JobStatus.PENDING,
|
|
227
|
+
created_at=datetime.now(UTC),
|
|
228
|
+
updated_at=datetime.now(UTC)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# This should not raise AttributeError
|
|
232
|
+
assert hasattr(job, 'input_media_gcs_path')
|
|
233
|
+
|
|
234
|
+
def test_input_media_gcs_path_can_be_set(self):
|
|
235
|
+
"""Test that input_media_gcs_path can be set."""
|
|
236
|
+
from backend.models.job import Job
|
|
237
|
+
from datetime import datetime, UTC
|
|
238
|
+
|
|
239
|
+
job = Job(
|
|
240
|
+
job_id="test123",
|
|
241
|
+
status=JobStatus.PENDING,
|
|
242
|
+
created_at=datetime.now(UTC),
|
|
243
|
+
updated_at=datetime.now(UTC),
|
|
244
|
+
input_media_gcs_path="uploads/test123/file.flac"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
assert job.input_media_gcs_path == "uploads/test123/file.flac"
|
|
248
|
+
|
|
249
|
+
def test_pydantic_doesnt_ignore_input_media_gcs_path(self):
|
|
250
|
+
"""Test that Pydantic includes input_media_gcs_path in serialization."""
|
|
251
|
+
from backend.models.job import Job
|
|
252
|
+
from datetime import datetime, UTC
|
|
253
|
+
|
|
254
|
+
job = Job(
|
|
255
|
+
job_id="test123",
|
|
256
|
+
status=JobStatus.PENDING,
|
|
257
|
+
created_at=datetime.now(UTC),
|
|
258
|
+
updated_at=datetime.now(UTC),
|
|
259
|
+
input_media_gcs_path="uploads/test123/file.flac"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
job_dict = job.model_dump()
|
|
263
|
+
|
|
264
|
+
# Pydantic should include it
|
|
265
|
+
assert "input_media_gcs_path" in job_dict
|
|
266
|
+
assert job_dict["input_media_gcs_path"] == "uploads/test123/file.flac"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestLyricsFileValidation:
|
|
270
|
+
"""Test lyrics file upload validation."""
|
|
271
|
+
|
|
272
|
+
@pytest.mark.parametrize("filename,expected_valid", [
|
|
273
|
+
("lyrics.txt", True),
|
|
274
|
+
("lyrics.docx", True),
|
|
275
|
+
("lyrics.rtf", True),
|
|
276
|
+
("lyrics.pdf", False),
|
|
277
|
+
("lyrics.mp3", False),
|
|
278
|
+
("lyrics", False),
|
|
279
|
+
])
|
|
280
|
+
def test_lyrics_file_extension_validation(self, filename, expected_valid):
|
|
281
|
+
"""Test that only valid lyrics file extensions are accepted."""
|
|
282
|
+
from pathlib import Path
|
|
283
|
+
|
|
284
|
+
allowed_extensions = {'.txt', '.docx', '.rtf'}
|
|
285
|
+
file_ext = Path(filename).suffix.lower()
|
|
286
|
+
|
|
287
|
+
is_valid = file_ext in allowed_extensions
|
|
288
|
+
assert is_valid == expected_valid
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TestLyricsConfigurationFields:
|
|
292
|
+
"""Test that lyrics configuration fields are handled correctly."""
|
|
293
|
+
|
|
294
|
+
def test_job_has_lyrics_artist_field(self):
|
|
295
|
+
"""Test that Job model has lyrics_artist field."""
|
|
296
|
+
from backend.models.job import Job
|
|
297
|
+
from datetime import datetime, UTC
|
|
298
|
+
|
|
299
|
+
job = Job(
|
|
300
|
+
job_id="test123",
|
|
301
|
+
status=JobStatus.PENDING,
|
|
302
|
+
created_at=datetime.now(UTC),
|
|
303
|
+
updated_at=datetime.now(UTC),
|
|
304
|
+
lyrics_artist="Override Artist"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
assert hasattr(job, 'lyrics_artist')
|
|
308
|
+
assert job.lyrics_artist == "Override Artist"
|
|
309
|
+
|
|
310
|
+
def test_job_has_lyrics_title_field(self):
|
|
311
|
+
"""Test that Job model has lyrics_title field."""
|
|
312
|
+
from backend.models.job import Job
|
|
313
|
+
from datetime import datetime, UTC
|
|
314
|
+
|
|
315
|
+
job = Job(
|
|
316
|
+
job_id="test123",
|
|
317
|
+
status=JobStatus.PENDING,
|
|
318
|
+
created_at=datetime.now(UTC),
|
|
319
|
+
updated_at=datetime.now(UTC),
|
|
320
|
+
lyrics_title="Override Title"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
assert hasattr(job, 'lyrics_title')
|
|
324
|
+
assert job.lyrics_title == "Override Title"
|
|
325
|
+
|
|
326
|
+
def test_job_has_lyrics_file_gcs_path_field(self):
|
|
327
|
+
"""Test that Job model has lyrics_file_gcs_path field."""
|
|
328
|
+
from backend.models.job import Job
|
|
329
|
+
from datetime import datetime, UTC
|
|
330
|
+
|
|
331
|
+
job = Job(
|
|
332
|
+
job_id="test123",
|
|
333
|
+
status=JobStatus.PENDING,
|
|
334
|
+
created_at=datetime.now(UTC),
|
|
335
|
+
updated_at=datetime.now(UTC),
|
|
336
|
+
lyrics_file_gcs_path="uploads/test123/lyrics/user_lyrics.txt"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
assert hasattr(job, 'lyrics_file_gcs_path')
|
|
340
|
+
assert job.lyrics_file_gcs_path == "uploads/test123/lyrics/user_lyrics.txt"
|
|
341
|
+
|
|
342
|
+
def test_job_has_subtitle_offset_ms_field(self):
|
|
343
|
+
"""Test that Job model has subtitle_offset_ms field."""
|
|
344
|
+
from backend.models.job import Job
|
|
345
|
+
from datetime import datetime, UTC
|
|
346
|
+
|
|
347
|
+
job = Job(
|
|
348
|
+
job_id="test123",
|
|
349
|
+
status=JobStatus.PENDING,
|
|
350
|
+
created_at=datetime.now(UTC),
|
|
351
|
+
updated_at=datetime.now(UTC),
|
|
352
|
+
subtitle_offset_ms=500
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
assert hasattr(job, 'subtitle_offset_ms')
|
|
356
|
+
assert job.subtitle_offset_ms == 500
|
|
357
|
+
|
|
358
|
+
def test_subtitle_offset_default_is_zero(self):
|
|
359
|
+
"""Test that subtitle_offset_ms defaults to 0."""
|
|
360
|
+
from backend.models.job import Job
|
|
361
|
+
from datetime import datetime, UTC
|
|
362
|
+
|
|
363
|
+
job = Job(
|
|
364
|
+
job_id="test123",
|
|
365
|
+
status=JobStatus.PENDING,
|
|
366
|
+
created_at=datetime.now(UTC),
|
|
367
|
+
updated_at=datetime.now(UTC)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
assert job.subtitle_offset_ms == 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class TestLyricsGCSPathGeneration:
|
|
374
|
+
"""Test lyrics file GCS path generation."""
|
|
375
|
+
|
|
376
|
+
def test_lyrics_gcs_path_format(self):
|
|
377
|
+
"""Test that lyrics GCS paths follow expected format."""
|
|
378
|
+
job_id = "test123"
|
|
379
|
+
filename = "user_lyrics.txt"
|
|
380
|
+
|
|
381
|
+
expected_path = f"uploads/{job_id}/lyrics/{filename}"
|
|
382
|
+
assert expected_path == "uploads/test123/lyrics/user_lyrics.txt"
|
|
383
|
+
|
|
384
|
+
def test_lyrics_gcs_path_preserves_extension(self):
|
|
385
|
+
"""Test that lyrics file extension is preserved."""
|
|
386
|
+
job_id = "test123"
|
|
387
|
+
|
|
388
|
+
for ext in ['.txt', '.docx', '.rtf']:
|
|
389
|
+
filename = f"user_lyrics{ext}"
|
|
390
|
+
gcs_path = f"uploads/{job_id}/lyrics/{filename}"
|
|
391
|
+
assert gcs_path.endswith(ext)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class TestJobCreateLyricsFields:
|
|
395
|
+
"""Test that JobCreate model supports lyrics fields."""
|
|
396
|
+
|
|
397
|
+
def test_job_create_has_lyrics_fields(self):
|
|
398
|
+
"""Test that JobCreate model has all lyrics configuration fields."""
|
|
399
|
+
from backend.models.job import JobCreate
|
|
400
|
+
|
|
401
|
+
job_create = JobCreate(
|
|
402
|
+
artist="Artist",
|
|
403
|
+
title="Title",
|
|
404
|
+
lyrics_artist="Override Artist",
|
|
405
|
+
lyrics_title="Override Title",
|
|
406
|
+
lyrics_file_gcs_path="uploads/test/lyrics/file.txt",
|
|
407
|
+
subtitle_offset_ms=250
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
assert job_create.lyrics_artist == "Override Artist"
|
|
411
|
+
assert job_create.lyrics_title == "Override Title"
|
|
412
|
+
assert job_create.lyrics_file_gcs_path == "uploads/test/lyrics/file.txt"
|
|
413
|
+
assert job_create.subtitle_offset_ms == 250
|
|
414
|
+
|
|
415
|
+
def test_job_create_lyrics_fields_optional(self):
|
|
416
|
+
"""Test that lyrics fields are optional in JobCreate."""
|
|
417
|
+
from backend.models.job import JobCreate
|
|
418
|
+
|
|
419
|
+
job_create = JobCreate(
|
|
420
|
+
artist="Artist",
|
|
421
|
+
title="Title"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
assert job_create.lyrics_artist is None
|
|
425
|
+
assert job_create.lyrics_title is None
|
|
426
|
+
assert job_create.lyrics_file_gcs_path is None
|
|
427
|
+
assert job_create.subtitle_offset_ms == 0
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class TestAudioModelConfigurationFields:
|
|
431
|
+
"""Test that Job and JobCreate models support audio model configuration fields."""
|
|
432
|
+
|
|
433
|
+
def test_job_has_clean_instrumental_model_field(self):
|
|
434
|
+
"""Test that Job model has clean_instrumental_model field."""
|
|
435
|
+
from backend.models.job import Job
|
|
436
|
+
from datetime import datetime, UTC
|
|
437
|
+
|
|
438
|
+
job = Job(
|
|
439
|
+
job_id="test123",
|
|
440
|
+
status=JobStatus.PENDING,
|
|
441
|
+
created_at=datetime.now(UTC),
|
|
442
|
+
updated_at=datetime.now(UTC),
|
|
443
|
+
clean_instrumental_model="custom_model.ckpt"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
assert hasattr(job, 'clean_instrumental_model')
|
|
447
|
+
assert job.clean_instrumental_model == "custom_model.ckpt"
|
|
448
|
+
|
|
449
|
+
def test_job_has_backing_vocals_models_field(self):
|
|
450
|
+
"""Test that Job model has backing_vocals_models field."""
|
|
451
|
+
from backend.models.job import Job
|
|
452
|
+
from datetime import datetime, UTC
|
|
453
|
+
|
|
454
|
+
job = Job(
|
|
455
|
+
job_id="test123",
|
|
456
|
+
status=JobStatus.PENDING,
|
|
457
|
+
created_at=datetime.now(UTC),
|
|
458
|
+
updated_at=datetime.now(UTC),
|
|
459
|
+
backing_vocals_models=["model1.ckpt", "model2.ckpt"]
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
assert hasattr(job, 'backing_vocals_models')
|
|
463
|
+
assert job.backing_vocals_models == ["model1.ckpt", "model2.ckpt"]
|
|
464
|
+
|
|
465
|
+
def test_job_has_other_stems_models_field(self):
|
|
466
|
+
"""Test that Job model has other_stems_models field."""
|
|
467
|
+
from backend.models.job import Job
|
|
468
|
+
from datetime import datetime, UTC
|
|
469
|
+
|
|
470
|
+
job = Job(
|
|
471
|
+
job_id="test123",
|
|
472
|
+
status=JobStatus.PENDING,
|
|
473
|
+
created_at=datetime.now(UTC),
|
|
474
|
+
updated_at=datetime.now(UTC),
|
|
475
|
+
other_stems_models=["htdemucs_6s.yaml"]
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
assert hasattr(job, 'other_stems_models')
|
|
479
|
+
assert job.other_stems_models == ["htdemucs_6s.yaml"]
|
|
480
|
+
|
|
481
|
+
def test_audio_model_fields_are_optional(self):
|
|
482
|
+
"""Test that audio model fields default to None."""
|
|
483
|
+
from backend.models.job import Job
|
|
484
|
+
from datetime import datetime, UTC
|
|
485
|
+
|
|
486
|
+
job = Job(
|
|
487
|
+
job_id="test123",
|
|
488
|
+
status=JobStatus.PENDING,
|
|
489
|
+
created_at=datetime.now(UTC),
|
|
490
|
+
updated_at=datetime.now(UTC)
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
assert job.clean_instrumental_model is None
|
|
494
|
+
assert job.backing_vocals_models is None
|
|
495
|
+
assert job.other_stems_models is None
|
|
496
|
+
|
|
497
|
+
def test_job_create_has_audio_model_fields(self):
|
|
498
|
+
"""Test that JobCreate model has all audio model configuration fields."""
|
|
499
|
+
from backend.models.job import JobCreate
|
|
500
|
+
|
|
501
|
+
job_create = JobCreate(
|
|
502
|
+
artist="Artist",
|
|
503
|
+
title="Title",
|
|
504
|
+
clean_instrumental_model="custom_clean.ckpt",
|
|
505
|
+
backing_vocals_models=["custom_bv.ckpt"],
|
|
506
|
+
other_stems_models=["custom_stems.yaml"]
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
assert job_create.clean_instrumental_model == "custom_clean.ckpt"
|
|
510
|
+
assert job_create.backing_vocals_models == ["custom_bv.ckpt"]
|
|
511
|
+
assert job_create.other_stems_models == ["custom_stems.yaml"]
|
|
512
|
+
|
|
513
|
+
def test_job_create_audio_model_fields_optional(self):
|
|
514
|
+
"""Test that audio model fields are optional in JobCreate."""
|
|
515
|
+
from backend.models.job import JobCreate
|
|
516
|
+
|
|
517
|
+
job_create = JobCreate(
|
|
518
|
+
artist="Artist",
|
|
519
|
+
title="Title"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
assert job_create.clean_instrumental_model is None
|
|
523
|
+
assert job_create.backing_vocals_models is None
|
|
524
|
+
assert job_create.other_stems_models is None
|
|
525
|
+
|
|
526
|
+
def test_pydantic_includes_audio_model_fields_in_serialization(self):
|
|
527
|
+
"""Test that Pydantic includes audio model fields in serialization."""
|
|
528
|
+
from backend.models.job import Job
|
|
529
|
+
from datetime import datetime, UTC
|
|
530
|
+
|
|
531
|
+
job = Job(
|
|
532
|
+
job_id="test123",
|
|
533
|
+
status=JobStatus.PENDING,
|
|
534
|
+
created_at=datetime.now(UTC),
|
|
535
|
+
updated_at=datetime.now(UTC),
|
|
536
|
+
clean_instrumental_model="custom.ckpt",
|
|
537
|
+
backing_vocals_models=["bv1.ckpt", "bv2.ckpt"],
|
|
538
|
+
other_stems_models=["stems.yaml"]
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
job_dict = job.model_dump()
|
|
542
|
+
|
|
543
|
+
assert "clean_instrumental_model" in job_dict
|
|
544
|
+
assert job_dict["clean_instrumental_model"] == "custom.ckpt"
|
|
545
|
+
assert "backing_vocals_models" in job_dict
|
|
546
|
+
assert job_dict["backing_vocals_models"] == ["bv1.ckpt", "bv2.ckpt"]
|
|
547
|
+
assert "other_stems_models" in job_dict
|
|
548
|
+
assert job_dict["other_stems_models"] == ["stems.yaml"]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class TestCommaDelimitedModelParsing:
|
|
552
|
+
"""Test parsing of comma-delimited model strings."""
|
|
553
|
+
|
|
554
|
+
def test_parse_single_model(self):
|
|
555
|
+
"""Test parsing a single model string."""
|
|
556
|
+
model_str = "model1.ckpt"
|
|
557
|
+
result = [m.strip() for m in model_str.split(',') if m.strip()]
|
|
558
|
+
assert result == ["model1.ckpt"]
|
|
559
|
+
|
|
560
|
+
def test_parse_multiple_models(self):
|
|
561
|
+
"""Test parsing multiple models."""
|
|
562
|
+
model_str = "model1.ckpt,model2.ckpt,model3.ckpt"
|
|
563
|
+
result = [m.strip() for m in model_str.split(',') if m.strip()]
|
|
564
|
+
assert result == ["model1.ckpt", "model2.ckpt", "model3.ckpt"]
|
|
565
|
+
|
|
566
|
+
def test_parse_models_with_whitespace(self):
|
|
567
|
+
"""Test parsing models with whitespace around commas."""
|
|
568
|
+
model_str = "model1.ckpt , model2.ckpt, model3.ckpt "
|
|
569
|
+
result = [m.strip() for m in model_str.split(',') if m.strip()]
|
|
570
|
+
assert result == ["model1.ckpt", "model2.ckpt", "model3.ckpt"]
|
|
571
|
+
|
|
572
|
+
def test_parse_empty_string(self):
|
|
573
|
+
"""Test parsing empty string."""
|
|
574
|
+
model_str = ""
|
|
575
|
+
result = [m.strip() for m in model_str.split(',') if m.strip()]
|
|
576
|
+
assert result == []
|
|
577
|
+
|
|
578
|
+
def test_parse_none_returns_none(self):
|
|
579
|
+
"""Test that None model string is handled correctly."""
|
|
580
|
+
model_str = None
|
|
581
|
+
result = None
|
|
582
|
+
if model_str:
|
|
583
|
+
result = [m.strip() for m in model_str.split(',') if m.strip()]
|
|
584
|
+
assert result is None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class TestSignedUrlUploadModels:
|
|
588
|
+
"""Test Pydantic models for signed URL upload flow."""
|
|
589
|
+
|
|
590
|
+
def test_file_upload_request_model(self):
|
|
591
|
+
"""Test FileUploadRequest model."""
|
|
592
|
+
from backend.api.routes.file_upload import FileUploadRequest
|
|
593
|
+
|
|
594
|
+
file_req = FileUploadRequest(
|
|
595
|
+
filename="test.flac",
|
|
596
|
+
content_type="audio/flac",
|
|
597
|
+
file_type="audio"
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
assert file_req.filename == "test.flac"
|
|
601
|
+
assert file_req.content_type == "audio/flac"
|
|
602
|
+
assert file_req.file_type == "audio"
|
|
603
|
+
|
|
604
|
+
def test_create_job_with_upload_urls_request_model(self):
|
|
605
|
+
"""Test CreateJobWithUploadUrlsRequest model."""
|
|
606
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
607
|
+
|
|
608
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
609
|
+
artist="Test Artist",
|
|
610
|
+
title="Test Song",
|
|
611
|
+
files=[
|
|
612
|
+
FileUploadRequest(filename="test.flac", content_type="audio/flac", file_type="audio")
|
|
613
|
+
],
|
|
614
|
+
enable_cdg=True,
|
|
615
|
+
enable_txt=True,
|
|
616
|
+
brand_prefix="NOMAD"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
assert request.artist == "Test Artist"
|
|
620
|
+
assert request.title == "Test Song"
|
|
621
|
+
assert len(request.files) == 1
|
|
622
|
+
assert request.files[0].file_type == "audio"
|
|
623
|
+
assert request.brand_prefix == "NOMAD"
|
|
624
|
+
|
|
625
|
+
def test_signed_upload_url_model(self):
|
|
626
|
+
"""Test SignedUploadUrl model."""
|
|
627
|
+
from backend.api.routes.file_upload import SignedUploadUrl
|
|
628
|
+
|
|
629
|
+
url_info = SignedUploadUrl(
|
|
630
|
+
file_type="audio",
|
|
631
|
+
gcs_path="uploads/test123/audio/test.flac",
|
|
632
|
+
upload_url="https://storage.googleapis.com/signed-url",
|
|
633
|
+
content_type="audio/flac"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
assert url_info.file_type == "audio"
|
|
637
|
+
assert url_info.gcs_path == "uploads/test123/audio/test.flac"
|
|
638
|
+
assert url_info.upload_url.startswith("https://")
|
|
639
|
+
assert url_info.content_type == "audio/flac"
|
|
640
|
+
|
|
641
|
+
def test_uploads_complete_request_model(self):
|
|
642
|
+
"""Test UploadsCompleteRequest model."""
|
|
643
|
+
from backend.api.routes.file_upload import UploadsCompleteRequest
|
|
644
|
+
|
|
645
|
+
request = UploadsCompleteRequest(
|
|
646
|
+
uploaded_files=["audio", "style_params", "style_intro_background"]
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
assert len(request.uploaded_files) == 3
|
|
650
|
+
assert "audio" in request.uploaded_files
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
class TestValidFileTypes:
|
|
654
|
+
"""Test file type validation for signed URL upload."""
|
|
655
|
+
|
|
656
|
+
def test_valid_file_types_includes_audio(self):
|
|
657
|
+
"""Test that audio is a valid file type."""
|
|
658
|
+
from backend.api.routes.file_upload import VALID_FILE_TYPES
|
|
659
|
+
|
|
660
|
+
assert 'audio' in VALID_FILE_TYPES
|
|
661
|
+
assert '.flac' in VALID_FILE_TYPES['audio']
|
|
662
|
+
assert '.mp3' in VALID_FILE_TYPES['audio']
|
|
663
|
+
|
|
664
|
+
def test_valid_file_types_includes_style_assets(self):
|
|
665
|
+
"""Test that all style assets are valid file types."""
|
|
666
|
+
from backend.api.routes.file_upload import VALID_FILE_TYPES
|
|
667
|
+
|
|
668
|
+
assert 'style_params' in VALID_FILE_TYPES
|
|
669
|
+
assert 'style_intro_background' in VALID_FILE_TYPES
|
|
670
|
+
assert 'style_karaoke_background' in VALID_FILE_TYPES
|
|
671
|
+
assert 'style_end_background' in VALID_FILE_TYPES
|
|
672
|
+
assert 'style_font' in VALID_FILE_TYPES
|
|
673
|
+
assert 'style_cdg_instrumental_background' in VALID_FILE_TYPES
|
|
674
|
+
assert 'style_cdg_title_background' in VALID_FILE_TYPES
|
|
675
|
+
assert 'style_cdg_outro_background' in VALID_FILE_TYPES
|
|
676
|
+
|
|
677
|
+
def test_valid_file_types_includes_lyrics(self):
|
|
678
|
+
"""Test that lyrics file is a valid file type."""
|
|
679
|
+
from backend.api.routes.file_upload import VALID_FILE_TYPES
|
|
680
|
+
|
|
681
|
+
assert 'lyrics_file' in VALID_FILE_TYPES
|
|
682
|
+
assert '.txt' in VALID_FILE_TYPES['lyrics_file']
|
|
683
|
+
assert '.docx' in VALID_FILE_TYPES['lyrics_file']
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class TestSignedUrlGCSPathGeneration:
|
|
687
|
+
"""Test GCS path generation for signed URL upload."""
|
|
688
|
+
|
|
689
|
+
def test_audio_gcs_path(self):
|
|
690
|
+
"""Test GCS path generation for audio file."""
|
|
691
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
692
|
+
|
|
693
|
+
path = _get_gcs_path_for_file("test123", "audio", "song.flac")
|
|
694
|
+
assert path == "uploads/test123/audio/song.flac"
|
|
695
|
+
|
|
696
|
+
def test_style_params_gcs_path(self):
|
|
697
|
+
"""Test GCS path generation for style params."""
|
|
698
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
699
|
+
|
|
700
|
+
path = _get_gcs_path_for_file("test123", "style_params", "style.json")
|
|
701
|
+
assert path == "uploads/test123/style/style_params.json"
|
|
702
|
+
|
|
703
|
+
def test_style_background_gcs_path(self):
|
|
704
|
+
"""Test GCS path generation for style background images."""
|
|
705
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
706
|
+
|
|
707
|
+
path = _get_gcs_path_for_file("test123", "style_intro_background", "bg.png")
|
|
708
|
+
assert path == "uploads/test123/style/intro_background.png"
|
|
709
|
+
|
|
710
|
+
def test_style_font_gcs_path(self):
|
|
711
|
+
"""Test GCS path generation for font file."""
|
|
712
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
713
|
+
|
|
714
|
+
path = _get_gcs_path_for_file("test123", "style_font", "font.ttf")
|
|
715
|
+
assert path == "uploads/test123/style/font.ttf"
|
|
716
|
+
|
|
717
|
+
def test_lyrics_file_gcs_path(self):
|
|
718
|
+
"""Test GCS path generation for lyrics file."""
|
|
719
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
720
|
+
|
|
721
|
+
path = _get_gcs_path_for_file("test123", "lyrics_file", "lyrics.txt")
|
|
722
|
+
assert path == "uploads/test123/lyrics/user_lyrics.txt"
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
class TestStorageServiceSignedUrls:
|
|
726
|
+
"""Test StorageService signed URL generation."""
|
|
727
|
+
|
|
728
|
+
def test_generate_signed_upload_url_method_exists(self):
|
|
729
|
+
"""Test that generate_signed_upload_url method exists in StorageService."""
|
|
730
|
+
from backend.services.storage_service import StorageService
|
|
731
|
+
|
|
732
|
+
assert hasattr(StorageService, 'generate_signed_upload_url')
|
|
733
|
+
|
|
734
|
+
def test_generate_signed_url_internal_method_exists(self):
|
|
735
|
+
"""Test that _generate_signed_url_internal method exists in StorageService."""
|
|
736
|
+
from backend.services.storage_service import StorageService
|
|
737
|
+
|
|
738
|
+
assert hasattr(StorageService, '_generate_signed_url_internal')
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class TestCreateJobWithUploadUrlsValidation:
|
|
742
|
+
"""Test validation for create_job_with_upload_urls endpoint."""
|
|
743
|
+
|
|
744
|
+
def test_request_without_audio_is_invalid(self):
|
|
745
|
+
"""Test that request without audio file is rejected."""
|
|
746
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
747
|
+
import pytest
|
|
748
|
+
|
|
749
|
+
# This should be caught during endpoint validation
|
|
750
|
+
# Here we test the model can be created but endpoint should reject
|
|
751
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
752
|
+
artist="Artist",
|
|
753
|
+
title="Title",
|
|
754
|
+
files=[
|
|
755
|
+
FileUploadRequest(filename="style.json", content_type="application/json", file_type="style_params")
|
|
756
|
+
]
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# Endpoint validation should catch this
|
|
760
|
+
audio_files = [f for f in request.files if f.file_type == 'audio']
|
|
761
|
+
assert len(audio_files) == 0 # No audio - should be rejected by endpoint
|
|
762
|
+
|
|
763
|
+
def test_request_with_multiple_audio_is_invalid(self):
|
|
764
|
+
"""Test that request with multiple audio files is rejected."""
|
|
765
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
766
|
+
|
|
767
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
768
|
+
artist="Artist",
|
|
769
|
+
title="Title",
|
|
770
|
+
files=[
|
|
771
|
+
FileUploadRequest(filename="song1.flac", content_type="audio/flac", file_type="audio"),
|
|
772
|
+
FileUploadRequest(filename="song2.flac", content_type="audio/flac", file_type="audio")
|
|
773
|
+
]
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
audio_files = [f for f in request.files if f.file_type == 'audio']
|
|
777
|
+
assert len(audio_files) == 2 # Multiple audio - should be rejected by endpoint
|
|
778
|
+
|
|
779
|
+
def test_request_with_invalid_file_type(self):
|
|
780
|
+
"""Test that request with invalid file type is rejected."""
|
|
781
|
+
from backend.api.routes.file_upload import VALID_FILE_TYPES
|
|
782
|
+
|
|
783
|
+
assert 'invalid_type' not in VALID_FILE_TYPES
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ============================================================================
|
|
787
|
+
# Batch 3: Existing Instrumental Tests
|
|
788
|
+
# ============================================================================
|
|
789
|
+
|
|
790
|
+
class TestExistingInstrumentalSupport:
|
|
791
|
+
"""Test existing instrumental support (Batch 3)."""
|
|
792
|
+
|
|
793
|
+
def test_valid_file_types_includes_existing_instrumental(self):
|
|
794
|
+
"""Test that existing_instrumental is a valid file type."""
|
|
795
|
+
from backend.api.routes.file_upload import VALID_FILE_TYPES, ALLOWED_AUDIO_EXTENSIONS
|
|
796
|
+
|
|
797
|
+
assert 'existing_instrumental' in VALID_FILE_TYPES
|
|
798
|
+
assert VALID_FILE_TYPES['existing_instrumental'] == ALLOWED_AUDIO_EXTENSIONS
|
|
799
|
+
|
|
800
|
+
def test_existing_instrumental_gcs_path(self):
|
|
801
|
+
"""Test GCS path generation for existing instrumental file."""
|
|
802
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
803
|
+
|
|
804
|
+
path = _get_gcs_path_for_file("test123", "existing_instrumental", "instrumental.flac")
|
|
805
|
+
assert path == "uploads/test123/audio/existing_instrumental.flac"
|
|
806
|
+
|
|
807
|
+
def test_existing_instrumental_gcs_path_mp3(self):
|
|
808
|
+
"""Test GCS path generation for existing instrumental MP3 file."""
|
|
809
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
810
|
+
|
|
811
|
+
path = _get_gcs_path_for_file("test123", "existing_instrumental", "instrumental.mp3")
|
|
812
|
+
assert path == "uploads/test123/audio/existing_instrumental.mp3"
|
|
813
|
+
|
|
814
|
+
def test_existing_instrumental_gcs_path_wav(self):
|
|
815
|
+
"""Test GCS path generation for existing instrumental WAV file."""
|
|
816
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_file
|
|
817
|
+
|
|
818
|
+
path = _get_gcs_path_for_file("test123", "existing_instrumental", "my_instrumental.wav")
|
|
819
|
+
assert path == "uploads/test123/audio/existing_instrumental.wav"
|
|
820
|
+
|
|
821
|
+
def test_job_has_existing_instrumental_gcs_path_field(self):
|
|
822
|
+
"""Test that Job model has existing_instrumental_gcs_path field."""
|
|
823
|
+
from backend.models.job import Job
|
|
824
|
+
from datetime import datetime, UTC
|
|
825
|
+
|
|
826
|
+
job = Job(
|
|
827
|
+
job_id="test123",
|
|
828
|
+
status=JobStatus.PENDING,
|
|
829
|
+
created_at=datetime.now(UTC),
|
|
830
|
+
updated_at=datetime.now(UTC),
|
|
831
|
+
existing_instrumental_gcs_path="uploads/test123/audio/existing_instrumental.flac"
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
assert hasattr(job, 'existing_instrumental_gcs_path')
|
|
835
|
+
assert job.existing_instrumental_gcs_path == "uploads/test123/audio/existing_instrumental.flac"
|
|
836
|
+
|
|
837
|
+
def test_existing_instrumental_gcs_path_optional(self):
|
|
838
|
+
"""Test that existing_instrumental_gcs_path is optional."""
|
|
839
|
+
from backend.models.job import Job
|
|
840
|
+
from datetime import datetime, UTC
|
|
841
|
+
|
|
842
|
+
job = Job(
|
|
843
|
+
job_id="test123",
|
|
844
|
+
status=JobStatus.PENDING,
|
|
845
|
+
created_at=datetime.now(UTC),
|
|
846
|
+
updated_at=datetime.now(UTC)
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
assert job.existing_instrumental_gcs_path is None
|
|
850
|
+
|
|
851
|
+
def test_job_create_has_existing_instrumental_gcs_path_field(self):
|
|
852
|
+
"""Test that JobCreate model has existing_instrumental_gcs_path field."""
|
|
853
|
+
from backend.models.job import JobCreate
|
|
854
|
+
|
|
855
|
+
job_create = JobCreate(
|
|
856
|
+
artist="Artist",
|
|
857
|
+
title="Title",
|
|
858
|
+
existing_instrumental_gcs_path="uploads/test123/audio/existing_instrumental.flac"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
assert hasattr(job_create, 'existing_instrumental_gcs_path')
|
|
862
|
+
assert job_create.existing_instrumental_gcs_path == "uploads/test123/audio/existing_instrumental.flac"
|
|
863
|
+
|
|
864
|
+
def test_job_create_existing_instrumental_optional(self):
|
|
865
|
+
"""Test that existing_instrumental_gcs_path is optional in JobCreate."""
|
|
866
|
+
from backend.models.job import JobCreate
|
|
867
|
+
|
|
868
|
+
job_create = JobCreate(
|
|
869
|
+
artist="Artist",
|
|
870
|
+
title="Title"
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
assert job_create.existing_instrumental_gcs_path is None
|
|
874
|
+
|
|
875
|
+
def test_pydantic_includes_existing_instrumental_in_serialization(self):
|
|
876
|
+
"""Test that Pydantic includes existing_instrumental_gcs_path in serialization."""
|
|
877
|
+
from backend.models.job import Job
|
|
878
|
+
from datetime import datetime, UTC
|
|
879
|
+
|
|
880
|
+
job = Job(
|
|
881
|
+
job_id="test123",
|
|
882
|
+
status=JobStatus.PENDING,
|
|
883
|
+
created_at=datetime.now(UTC),
|
|
884
|
+
updated_at=datetime.now(UTC),
|
|
885
|
+
existing_instrumental_gcs_path="uploads/test123/audio/existing_instrumental.flac"
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
job_dict = job.model_dump()
|
|
889
|
+
|
|
890
|
+
assert "existing_instrumental_gcs_path" in job_dict
|
|
891
|
+
assert job_dict["existing_instrumental_gcs_path"] == "uploads/test123/audio/existing_instrumental.flac"
|
|
892
|
+
|
|
893
|
+
@pytest.mark.parametrize("filename,expected_valid", [
|
|
894
|
+
("instrumental.mp3", True),
|
|
895
|
+
("instrumental.flac", True),
|
|
896
|
+
("instrumental.wav", True),
|
|
897
|
+
("instrumental.m4a", True),
|
|
898
|
+
("instrumental.ogg", True),
|
|
899
|
+
("instrumental.aac", True),
|
|
900
|
+
("instrumental.txt", False),
|
|
901
|
+
("instrumental.pdf", False),
|
|
902
|
+
])
|
|
903
|
+
def test_existing_instrumental_extension_validation(self, filename, expected_valid):
|
|
904
|
+
"""Test that only valid audio extensions are accepted for existing instrumental."""
|
|
905
|
+
from pathlib import Path
|
|
906
|
+
from backend.api.routes.file_upload import VALID_FILE_TYPES
|
|
907
|
+
|
|
908
|
+
allowed_extensions = VALID_FILE_TYPES['existing_instrumental']
|
|
909
|
+
file_ext = Path(filename).suffix.lower()
|
|
910
|
+
|
|
911
|
+
is_valid = file_ext in allowed_extensions
|
|
912
|
+
assert is_valid == expected_valid
|
|
913
|
+
|
|
914
|
+
def test_create_job_with_upload_urls_request_has_existing_instrumental_flag(self):
|
|
915
|
+
"""Test that CreateJobWithUploadUrlsRequest has existing_instrumental field."""
|
|
916
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
917
|
+
|
|
918
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
919
|
+
artist="Test Artist",
|
|
920
|
+
title="Test Song",
|
|
921
|
+
files=[
|
|
922
|
+
FileUploadRequest(filename="test.flac", content_type="audio/flac", file_type="audio"),
|
|
923
|
+
FileUploadRequest(filename="instr.flac", content_type="audio/flac", file_type="existing_instrumental"),
|
|
924
|
+
],
|
|
925
|
+
existing_instrumental=True
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
assert request.existing_instrumental is True
|
|
929
|
+
|
|
930
|
+
def test_create_job_with_upload_urls_request_existing_instrumental_default_false(self):
|
|
931
|
+
"""Test that existing_instrumental defaults to False."""
|
|
932
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
933
|
+
|
|
934
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
935
|
+
artist="Test Artist",
|
|
936
|
+
title="Test Song",
|
|
937
|
+
files=[
|
|
938
|
+
FileUploadRequest(filename="test.flac", content_type="audio/flac", file_type="audio"),
|
|
939
|
+
]
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
assert request.existing_instrumental is False
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
class TestDurationValidation:
|
|
946
|
+
"""Test audio duration validation for existing instrumental (Batch 3)."""
|
|
947
|
+
|
|
948
|
+
def test_validate_audio_durations_function_exists(self):
|
|
949
|
+
"""Test that _validate_audio_durations function exists."""
|
|
950
|
+
from backend.api.routes.file_upload import _validate_audio_durations
|
|
951
|
+
|
|
952
|
+
assert callable(_validate_audio_durations)
|
|
953
|
+
|
|
954
|
+
@pytest.mark.asyncio
|
|
955
|
+
async def test_duration_validation_returns_tuple(self):
|
|
956
|
+
"""Test that duration validation returns correct tuple structure."""
|
|
957
|
+
# This is a structural test - actual implementation tested in integration tests
|
|
958
|
+
# The function should return (is_valid: bool, audio_duration: float, instrumental_duration: float)
|
|
959
|
+
from backend.api.routes.file_upload import _validate_audio_durations
|
|
960
|
+
import inspect
|
|
961
|
+
|
|
962
|
+
# Verify it's an async function
|
|
963
|
+
assert inspect.iscoroutinefunction(_validate_audio_durations)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
class TestTwoPhaseWorkflowModels:
|
|
967
|
+
"""Test Pydantic models for two-phase workflow (Batch 6)."""
|
|
968
|
+
|
|
969
|
+
def test_create_job_with_upload_urls_request_has_prep_only(self):
|
|
970
|
+
"""Test that CreateJobWithUploadUrlsRequest has prep_only field."""
|
|
971
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
972
|
+
|
|
973
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
974
|
+
artist="Test Artist",
|
|
975
|
+
title="Test Song",
|
|
976
|
+
files=[
|
|
977
|
+
FileUploadRequest(filename="test.flac", content_type="audio/flac", file_type="audio")
|
|
978
|
+
],
|
|
979
|
+
prep_only=True
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
assert request.prep_only is True
|
|
983
|
+
|
|
984
|
+
def test_create_job_with_upload_urls_request_prep_only_default_false(self):
|
|
985
|
+
"""Test that prep_only defaults to False."""
|
|
986
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
987
|
+
|
|
988
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
989
|
+
artist="Test Artist",
|
|
990
|
+
title="Test Song",
|
|
991
|
+
files=[
|
|
992
|
+
FileUploadRequest(filename="test.flac", content_type="audio/flac", file_type="audio")
|
|
993
|
+
]
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
assert request.prep_only is False
|
|
997
|
+
|
|
998
|
+
def test_create_job_with_upload_urls_request_has_keep_brand_code(self):
|
|
999
|
+
"""Test that CreateJobWithUploadUrlsRequest has keep_brand_code field."""
|
|
1000
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
1001
|
+
|
|
1002
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
1003
|
+
artist="Test Artist",
|
|
1004
|
+
title="Test Song",
|
|
1005
|
+
files=[
|
|
1006
|
+
FileUploadRequest(filename="test.flac", content_type="audio/flac", file_type="audio")
|
|
1007
|
+
],
|
|
1008
|
+
keep_brand_code="NOMAD-1234"
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
assert request.keep_brand_code == "NOMAD-1234"
|
|
1012
|
+
|
|
1013
|
+
def test_create_job_with_upload_urls_request_keep_brand_code_default_none(self):
|
|
1014
|
+
"""Test that keep_brand_code defaults to None."""
|
|
1015
|
+
from backend.api.routes.file_upload import CreateJobWithUploadUrlsRequest, FileUploadRequest
|
|
1016
|
+
|
|
1017
|
+
request = CreateJobWithUploadUrlsRequest(
|
|
1018
|
+
artist="Test Artist",
|
|
1019
|
+
title="Test Song",
|
|
1020
|
+
files=[
|
|
1021
|
+
FileUploadRequest(filename="test.flac", content_type="audio/flac", file_type="audio")
|
|
1022
|
+
]
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
assert request.keep_brand_code is None
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
class TestFinaliseOnlyFileTypes:
|
|
1029
|
+
"""Test finalise-only file types (Batch 6)."""
|
|
1030
|
+
|
|
1031
|
+
def test_finalise_only_file_types_exists(self):
|
|
1032
|
+
"""Test that FINALISE_ONLY_FILE_TYPES is defined."""
|
|
1033
|
+
from backend.api.routes.file_upload import FINALISE_ONLY_FILE_TYPES
|
|
1034
|
+
|
|
1035
|
+
assert FINALISE_ONLY_FILE_TYPES is not None
|
|
1036
|
+
assert isinstance(FINALISE_ONLY_FILE_TYPES, dict)
|
|
1037
|
+
|
|
1038
|
+
def test_finalise_only_file_types_has_with_vocals(self):
|
|
1039
|
+
"""Test that with_vocals is a valid finalise-only file type."""
|
|
1040
|
+
from backend.api.routes.file_upload import FINALISE_ONLY_FILE_TYPES
|
|
1041
|
+
|
|
1042
|
+
assert 'with_vocals' in FINALISE_ONLY_FILE_TYPES
|
|
1043
|
+
assert '.mkv' in FINALISE_ONLY_FILE_TYPES['with_vocals']
|
|
1044
|
+
assert '.mov' in FINALISE_ONLY_FILE_TYPES['with_vocals']
|
|
1045
|
+
|
|
1046
|
+
def test_finalise_only_file_types_has_title_screen(self):
|
|
1047
|
+
"""Test that title_screen is a valid finalise-only file type."""
|
|
1048
|
+
from backend.api.routes.file_upload import FINALISE_ONLY_FILE_TYPES
|
|
1049
|
+
|
|
1050
|
+
assert 'title_screen' in FINALISE_ONLY_FILE_TYPES
|
|
1051
|
+
|
|
1052
|
+
def test_finalise_only_file_types_has_end_screen(self):
|
|
1053
|
+
"""Test that end_screen is a valid finalise-only file type."""
|
|
1054
|
+
from backend.api.routes.file_upload import FINALISE_ONLY_FILE_TYPES
|
|
1055
|
+
|
|
1056
|
+
assert 'end_screen' in FINALISE_ONLY_FILE_TYPES
|
|
1057
|
+
|
|
1058
|
+
def test_finalise_only_file_types_has_instrumentals(self):
|
|
1059
|
+
"""Test that instrumental types are valid finalise-only file types."""
|
|
1060
|
+
from backend.api.routes.file_upload import FINALISE_ONLY_FILE_TYPES
|
|
1061
|
+
|
|
1062
|
+
assert 'instrumental_clean' in FINALISE_ONLY_FILE_TYPES
|
|
1063
|
+
assert 'instrumental_backing' in FINALISE_ONLY_FILE_TYPES
|
|
1064
|
+
|
|
1065
|
+
def test_finalise_only_file_types_has_lrc(self):
|
|
1066
|
+
"""Test that lrc is a valid finalise-only file type."""
|
|
1067
|
+
from backend.api.routes.file_upload import FINALISE_ONLY_FILE_TYPES
|
|
1068
|
+
|
|
1069
|
+
assert 'lrc' in FINALISE_ONLY_FILE_TYPES
|
|
1070
|
+
assert '.lrc' in FINALISE_ONLY_FILE_TYPES['lrc']
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
class TestFinaliseOnlyModels:
|
|
1074
|
+
"""Test Pydantic models for finalise-only flow (Batch 6)."""
|
|
1075
|
+
|
|
1076
|
+
def test_finalise_only_file_request_model(self):
|
|
1077
|
+
"""Test FinaliseOnlyFileRequest model."""
|
|
1078
|
+
from backend.api.routes.file_upload import FinaliseOnlyFileRequest
|
|
1079
|
+
|
|
1080
|
+
file_req = FinaliseOnlyFileRequest(
|
|
1081
|
+
filename="with_vocals.mkv",
|
|
1082
|
+
content_type="video/mkv",
|
|
1083
|
+
file_type="with_vocals"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
assert file_req.filename == "with_vocals.mkv"
|
|
1087
|
+
assert file_req.content_type == "video/mkv"
|
|
1088
|
+
assert file_req.file_type == "with_vocals"
|
|
1089
|
+
|
|
1090
|
+
def test_create_finalise_only_job_request_model(self):
|
|
1091
|
+
"""Test CreateFinaliseOnlyJobRequest model."""
|
|
1092
|
+
from backend.api.routes.file_upload import CreateFinaliseOnlyJobRequest, FinaliseOnlyFileRequest
|
|
1093
|
+
|
|
1094
|
+
request = CreateFinaliseOnlyJobRequest(
|
|
1095
|
+
artist="Test Artist",
|
|
1096
|
+
title="Test Song",
|
|
1097
|
+
files=[
|
|
1098
|
+
FinaliseOnlyFileRequest(filename="with_vocals.mkv", content_type="video/mkv", file_type="with_vocals"),
|
|
1099
|
+
FinaliseOnlyFileRequest(filename="title.mov", content_type="video/quicktime", file_type="title_screen"),
|
|
1100
|
+
FinaliseOnlyFileRequest(filename="end.mov", content_type="video/quicktime", file_type="end_screen"),
|
|
1101
|
+
FinaliseOnlyFileRequest(filename="instrumental.flac", content_type="audio/flac", file_type="instrumental_clean"),
|
|
1102
|
+
],
|
|
1103
|
+
enable_cdg=True,
|
|
1104
|
+
enable_txt=True,
|
|
1105
|
+
brand_prefix="NOMAD",
|
|
1106
|
+
keep_brand_code="NOMAD-1234"
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
assert request.artist == "Test Artist"
|
|
1110
|
+
assert request.title == "Test Song"
|
|
1111
|
+
assert len(request.files) == 4
|
|
1112
|
+
assert request.keep_brand_code == "NOMAD-1234"
|
|
1113
|
+
|
|
1114
|
+
def test_create_finalise_only_job_request_optional_fields(self):
|
|
1115
|
+
"""Test CreateFinaliseOnlyJobRequest optional fields default correctly."""
|
|
1116
|
+
from backend.api.routes.file_upload import CreateFinaliseOnlyJobRequest, FinaliseOnlyFileRequest
|
|
1117
|
+
|
|
1118
|
+
request = CreateFinaliseOnlyJobRequest(
|
|
1119
|
+
artist="Artist",
|
|
1120
|
+
title="Title",
|
|
1121
|
+
files=[
|
|
1122
|
+
FinaliseOnlyFileRequest(filename="with_vocals.mkv", content_type="video/mkv", file_type="with_vocals"),
|
|
1123
|
+
]
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
# CDG/TXT default to None (server resolves based on theme_id)
|
|
1127
|
+
assert request.enable_cdg is None
|
|
1128
|
+
assert request.enable_txt is None
|
|
1129
|
+
assert request.brand_prefix is None
|
|
1130
|
+
assert request.keep_brand_code is None
|
|
1131
|
+
# YouTube upload default is None (server applies default_enable_youtube_upload)
|
|
1132
|
+
assert request.enable_youtube_upload is None
|
|
1133
|
+
assert request.dropbox_path is None
|
|
1134
|
+
assert request.gdrive_folder_id is None
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
class TestFinaliseOnlyGCSPaths:
|
|
1138
|
+
"""Test GCS path generation for finalise-only files (Batch 6)."""
|
|
1139
|
+
|
|
1140
|
+
def test_with_vocals_gcs_path(self):
|
|
1141
|
+
"""Test GCS path generation for with_vocals file."""
|
|
1142
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_finalise_file
|
|
1143
|
+
|
|
1144
|
+
path = _get_gcs_path_for_finalise_file("test123", "with_vocals", "video.mkv")
|
|
1145
|
+
assert path == "jobs/test123/videos/with_vocals.mkv"
|
|
1146
|
+
|
|
1147
|
+
def test_title_screen_gcs_path(self):
|
|
1148
|
+
"""Test GCS path generation for title_screen file."""
|
|
1149
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_finalise_file
|
|
1150
|
+
|
|
1151
|
+
path = _get_gcs_path_for_finalise_file("test123", "title_screen", "title.mov")
|
|
1152
|
+
assert path == "jobs/test123/screens/title.mov"
|
|
1153
|
+
|
|
1154
|
+
def test_end_screen_gcs_path(self):
|
|
1155
|
+
"""Test GCS path generation for end_screen file."""
|
|
1156
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_finalise_file
|
|
1157
|
+
|
|
1158
|
+
path = _get_gcs_path_for_finalise_file("test123", "end_screen", "end.mov")
|
|
1159
|
+
assert path == "jobs/test123/screens/end.mov"
|
|
1160
|
+
|
|
1161
|
+
def test_instrumental_clean_gcs_path(self):
|
|
1162
|
+
"""Test GCS path generation for instrumental_clean file."""
|
|
1163
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_finalise_file
|
|
1164
|
+
|
|
1165
|
+
path = _get_gcs_path_for_finalise_file("test123", "instrumental_clean", "clean.flac")
|
|
1166
|
+
assert path == "jobs/test123/stems/instrumental_clean.flac"
|
|
1167
|
+
|
|
1168
|
+
def test_instrumental_backing_gcs_path(self):
|
|
1169
|
+
"""Test GCS path generation for instrumental_backing file."""
|
|
1170
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_finalise_file
|
|
1171
|
+
|
|
1172
|
+
path = _get_gcs_path_for_finalise_file("test123", "instrumental_backing", "backing.flac")
|
|
1173
|
+
assert path == "jobs/test123/stems/instrumental_with_backing.flac"
|
|
1174
|
+
|
|
1175
|
+
def test_lrc_gcs_path(self):
|
|
1176
|
+
"""Test GCS path generation for lrc file."""
|
|
1177
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_finalise_file
|
|
1178
|
+
|
|
1179
|
+
path = _get_gcs_path_for_finalise_file("test123", "lrc", "karaoke.lrc")
|
|
1180
|
+
assert path == "jobs/test123/lyrics/karaoke.lrc"
|
|
1181
|
+
|
|
1182
|
+
def test_title_jpg_gcs_path(self):
|
|
1183
|
+
"""Test GCS path generation for title_jpg file."""
|
|
1184
|
+
from backend.api.routes.file_upload import _get_gcs_path_for_finalise_file
|
|
1185
|
+
|
|
1186
|
+
path = _get_gcs_path_for_finalise_file("test123", "title_jpg", "title.jpg")
|
|
1187
|
+
assert path == "jobs/test123/screens/title.jpg"
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
# ============================================================================
|
|
1191
|
+
# Batch 4: YouTube URL Input Tests
|
|
1192
|
+
# ============================================================================
|
|
1193
|
+
|
|
1194
|
+
class TestURLValidation:
|
|
1195
|
+
"""Test URL validation for URL-based job submission."""
|
|
1196
|
+
|
|
1197
|
+
def test_valid_youtube_urls(self):
|
|
1198
|
+
"""Test that YouTube URLs are validated correctly."""
|
|
1199
|
+
from backend.api.routes.file_upload import _validate_url
|
|
1200
|
+
|
|
1201
|
+
valid_urls = [
|
|
1202
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1203
|
+
"https://youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1204
|
+
"https://youtu.be/dQw4w9WgXcQ",
|
|
1205
|
+
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1206
|
+
]
|
|
1207
|
+
|
|
1208
|
+
for url in valid_urls:
|
|
1209
|
+
assert _validate_url(url), f"URL should be valid: {url}"
|
|
1210
|
+
|
|
1211
|
+
def test_valid_vimeo_urls(self):
|
|
1212
|
+
"""Test that Vimeo URLs are validated correctly."""
|
|
1213
|
+
from backend.api.routes.file_upload import _validate_url
|
|
1214
|
+
|
|
1215
|
+
valid_urls = [
|
|
1216
|
+
"https://vimeo.com/123456789",
|
|
1217
|
+
"https://www.vimeo.com/123456789",
|
|
1218
|
+
]
|
|
1219
|
+
|
|
1220
|
+
for url in valid_urls:
|
|
1221
|
+
assert _validate_url(url), f"URL should be valid: {url}"
|
|
1222
|
+
|
|
1223
|
+
def test_valid_soundcloud_urls(self):
|
|
1224
|
+
"""Test that SoundCloud URLs are validated correctly."""
|
|
1225
|
+
from backend.api.routes.file_upload import _validate_url
|
|
1226
|
+
|
|
1227
|
+
valid_urls = [
|
|
1228
|
+
"https://soundcloud.com/artist/track",
|
|
1229
|
+
"https://www.soundcloud.com/artist/track",
|
|
1230
|
+
]
|
|
1231
|
+
|
|
1232
|
+
for url in valid_urls:
|
|
1233
|
+
assert _validate_url(url), f"URL should be valid: {url}"
|
|
1234
|
+
|
|
1235
|
+
def test_invalid_urls(self):
|
|
1236
|
+
"""Test that invalid URLs are rejected."""
|
|
1237
|
+
from backend.api.routes.file_upload import _validate_url
|
|
1238
|
+
|
|
1239
|
+
invalid_urls = [
|
|
1240
|
+
"",
|
|
1241
|
+
None,
|
|
1242
|
+
"not-a-url",
|
|
1243
|
+
"ftp://example.com/file.mp3",
|
|
1244
|
+
]
|
|
1245
|
+
|
|
1246
|
+
for url in invalid_urls:
|
|
1247
|
+
assert not _validate_url(url), f"URL should be invalid: {url}"
|
|
1248
|
+
|
|
1249
|
+
def test_other_supported_platforms(self):
|
|
1250
|
+
"""Test other supported video platforms."""
|
|
1251
|
+
from backend.api.routes.file_upload import _validate_url
|
|
1252
|
+
|
|
1253
|
+
valid_urls = [
|
|
1254
|
+
"https://twitter.com/user/status/123",
|
|
1255
|
+
"https://x.com/user/status/123",
|
|
1256
|
+
"https://www.facebook.com/video.php?v=123",
|
|
1257
|
+
"https://www.instagram.com/reel/abc123/",
|
|
1258
|
+
"https://www.tiktok.com/@user/video/123",
|
|
1259
|
+
]
|
|
1260
|
+
|
|
1261
|
+
for url in valid_urls:
|
|
1262
|
+
assert _validate_url(url), f"URL should be valid: {url}"
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
class TestCreateJobFromUrlRequest:
|
|
1266
|
+
"""Test CreateJobFromUrlRequest Pydantic model."""
|
|
1267
|
+
|
|
1268
|
+
def test_create_with_url_only(self):
|
|
1269
|
+
"""Test creating request with just URL (artist/title auto-detected)."""
|
|
1270
|
+
from backend.api.routes.file_upload import CreateJobFromUrlRequest
|
|
1271
|
+
|
|
1272
|
+
request = CreateJobFromUrlRequest(
|
|
1273
|
+
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
assert request.url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1277
|
+
assert request.artist is None
|
|
1278
|
+
assert request.title is None
|
|
1279
|
+
# CDG/TXT default to None (server resolves based on theme_id)
|
|
1280
|
+
assert request.enable_cdg is None
|
|
1281
|
+
assert request.enable_txt is None
|
|
1282
|
+
|
|
1283
|
+
def test_create_with_artist_and_title(self):
|
|
1284
|
+
"""Test creating request with URL, artist, and title."""
|
|
1285
|
+
from backend.api.routes.file_upload import CreateJobFromUrlRequest
|
|
1286
|
+
|
|
1287
|
+
request = CreateJobFromUrlRequest(
|
|
1288
|
+
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1289
|
+
artist="Rick Astley",
|
|
1290
|
+
title="Never Gonna Give You Up"
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
assert request.url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1294
|
+
assert request.artist == "Rick Astley"
|
|
1295
|
+
assert request.title == "Never Gonna Give You Up"
|
|
1296
|
+
|
|
1297
|
+
def test_create_with_all_options(self):
|
|
1298
|
+
"""Test creating request with all options."""
|
|
1299
|
+
from backend.api.routes.file_upload import CreateJobFromUrlRequest
|
|
1300
|
+
|
|
1301
|
+
request = CreateJobFromUrlRequest(
|
|
1302
|
+
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1303
|
+
artist="Rick Astley",
|
|
1304
|
+
title="Never Gonna Give You Up",
|
|
1305
|
+
enable_cdg=True,
|
|
1306
|
+
enable_txt=True,
|
|
1307
|
+
brand_prefix="NOMAD",
|
|
1308
|
+
enable_youtube_upload=True,
|
|
1309
|
+
dropbox_path="/Karaoke/Test",
|
|
1310
|
+
gdrive_folder_id="abc123",
|
|
1311
|
+
lyrics_artist="Rick A.",
|
|
1312
|
+
lyrics_title="Never Gonna",
|
|
1313
|
+
subtitle_offset_ms=500,
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
assert request.brand_prefix == "NOMAD"
|
|
1317
|
+
assert request.enable_youtube_upload is True
|
|
1318
|
+
assert request.dropbox_path == "/Karaoke/Test"
|
|
1319
|
+
assert request.gdrive_folder_id == "abc123"
|
|
1320
|
+
assert request.lyrics_artist == "Rick A."
|
|
1321
|
+
assert request.subtitle_offset_ms == 500
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
class TestCreateJobFromUrlResponse:
|
|
1325
|
+
"""Test CreateJobFromUrlResponse Pydantic model."""
|
|
1326
|
+
|
|
1327
|
+
def test_response_model(self):
|
|
1328
|
+
"""Test response model fields."""
|
|
1329
|
+
from backend.api.routes.file_upload import CreateJobFromUrlResponse
|
|
1330
|
+
|
|
1331
|
+
response = CreateJobFromUrlResponse(
|
|
1332
|
+
status="success",
|
|
1333
|
+
job_id="test123",
|
|
1334
|
+
message="Job created. Audio will be downloaded from URL.",
|
|
1335
|
+
detected_artist="Rick Astley",
|
|
1336
|
+
detected_title="Never Gonna Give You Up",
|
|
1337
|
+
server_version="0.71.26"
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
assert response.status == "success"
|
|
1341
|
+
assert response.job_id == "test123"
|
|
1342
|
+
assert response.detected_artist == "Rick Astley"
|
|
1343
|
+
assert response.detected_title == "Never Gonna Give You Up"
|
|
1344
|
+
|
|
1345
|
+
def test_response_with_none_artist_title(self):
|
|
1346
|
+
"""Test response when artist/title are not provided (auto-detection)."""
|
|
1347
|
+
from backend.api.routes.file_upload import CreateJobFromUrlResponse
|
|
1348
|
+
|
|
1349
|
+
response = CreateJobFromUrlResponse(
|
|
1350
|
+
status="success",
|
|
1351
|
+
job_id="test123",
|
|
1352
|
+
message="Job created. Audio will be downloaded from URL.",
|
|
1353
|
+
detected_artist=None,
|
|
1354
|
+
detected_title=None,
|
|
1355
|
+
server_version="0.71.26"
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
assert response.detected_artist is None
|
|
1359
|
+
assert response.detected_title is None
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
class TestFileHandlerDownloadVideo:
|
|
1363
|
+
"""Test FileHandler.download_video method."""
|
|
1364
|
+
|
|
1365
|
+
def test_download_video_method_exists(self):
|
|
1366
|
+
"""Test that download_video method exists in FileHandler."""
|
|
1367
|
+
from karaoke_gen.file_handler import FileHandler
|
|
1368
|
+
|
|
1369
|
+
assert hasattr(FileHandler, 'download_video')
|
|
1370
|
+
|
|
1371
|
+
def test_extract_metadata_from_url_method_exists(self):
|
|
1372
|
+
"""Test that extract_metadata_from_url method exists in FileHandler."""
|
|
1373
|
+
from karaoke_gen.file_handler import FileHandler
|
|
1374
|
+
|
|
1375
|
+
assert hasattr(FileHandler, 'extract_metadata_from_url')
|
|
1376
|
+
|
|
1377
|
+
def test_yt_dlp_import_check(self):
|
|
1378
|
+
"""Test that YT_DLP_AVAILABLE flag is set correctly."""
|
|
1379
|
+
from karaoke_gen.file_handler import YT_DLP_AVAILABLE
|
|
1380
|
+
|
|
1381
|
+
# Should be True if yt-dlp is installed
|
|
1382
|
+
# Test just checks the flag exists
|
|
1383
|
+
assert isinstance(YT_DLP_AVAILABLE, bool)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
class TestJobModelURLField:
|
|
1387
|
+
"""Test that Job model supports URL field correctly."""
|
|
1388
|
+
|
|
1389
|
+
def test_job_has_url_field(self):
|
|
1390
|
+
"""Test that Job model has url field."""
|
|
1391
|
+
from backend.models.job import Job
|
|
1392
|
+
from datetime import datetime, UTC
|
|
1393
|
+
|
|
1394
|
+
job = Job(
|
|
1395
|
+
job_id="test123",
|
|
1396
|
+
status=JobStatus.PENDING,
|
|
1397
|
+
created_at=datetime.now(UTC),
|
|
1398
|
+
updated_at=datetime.now(UTC),
|
|
1399
|
+
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
assert hasattr(job, 'url')
|
|
1403
|
+
assert job.url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1404
|
+
|
|
1405
|
+
def test_job_url_is_optional(self):
|
|
1406
|
+
"""Test that url field is optional."""
|
|
1407
|
+
from backend.models.job import Job
|
|
1408
|
+
from datetime import datetime, UTC
|
|
1409
|
+
|
|
1410
|
+
job = Job(
|
|
1411
|
+
job_id="test123",
|
|
1412
|
+
status=JobStatus.PENDING,
|
|
1413
|
+
created_at=datetime.now(UTC),
|
|
1414
|
+
updated_at=datetime.now(UTC)
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
assert job.url is None
|
|
1418
|
+
|
|
1419
|
+
def test_job_create_with_url(self):
|
|
1420
|
+
"""Test creating job via JobCreate with URL."""
|
|
1421
|
+
from backend.models.job import JobCreate
|
|
1422
|
+
|
|
1423
|
+
job_create = JobCreate(
|
|
1424
|
+
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1425
|
+
artist="Rick Astley",
|
|
1426
|
+
title="Never Gonna Give You Up"
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
assert job_create.url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1430
|
+
assert job_create.artist == "Rick Astley"
|
|
1431
|
+
assert job_create.title == "Never Gonna Give You Up"
|
|
1432
|
+
|
|
1433
|
+
def test_job_create_url_only(self):
|
|
1434
|
+
"""Test creating job via JobCreate with URL only (no artist/title)."""
|
|
1435
|
+
from backend.models.job import JobCreate
|
|
1436
|
+
|
|
1437
|
+
job_create = JobCreate(
|
|
1438
|
+
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
assert job_create.url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1442
|
+
assert job_create.artist is None
|
|
1443
|
+
assert job_create.title is None
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
class TestCreateJobFromUrlEndpoint:
|
|
1447
|
+
"""Test the /api/jobs/create-from-url endpoint."""
|
|
1448
|
+
|
|
1449
|
+
def test_endpoint_exists(self):
|
|
1450
|
+
"""Test that the create-from-url endpoint exists on the router."""
|
|
1451
|
+
from backend.api.routes.file_upload import router
|
|
1452
|
+
|
|
1453
|
+
# Check if the route exists
|
|
1454
|
+
paths = [route.path for route in router.routes if hasattr(route, 'path')]
|
|
1455
|
+
assert "/jobs/create-from-url" in paths
|
|
1456
|
+
|
|
1457
|
+
def test_create_job_from_url_response_model_has_expected_fields(self):
|
|
1458
|
+
"""Test that CreateJobFromUrlResponse has the expected fields."""
|
|
1459
|
+
from backend.api.routes.file_upload import CreateJobFromUrlResponse
|
|
1460
|
+
|
|
1461
|
+
# Create instance with all required fields
|
|
1462
|
+
response = CreateJobFromUrlResponse(
|
|
1463
|
+
status="success",
|
|
1464
|
+
job_id="test123",
|
|
1465
|
+
message="Test message",
|
|
1466
|
+
detected_artist=None,
|
|
1467
|
+
detected_title=None,
|
|
1468
|
+
server_version="1.0.0"
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
assert response.status == "success"
|
|
1472
|
+
assert response.job_id == "test123"
|
|
1473
|
+
assert response.message == "Test message"
|
|
1474
|
+
assert response.detected_artist is None
|
|
1475
|
+
assert response.detected_title is None
|
|
1476
|
+
|
|
1477
|
+
def test_create_job_from_url_response_with_all_fields(self):
|
|
1478
|
+
"""Test CreateJobFromUrlResponse with all fields populated."""
|
|
1479
|
+
from backend.api.routes.file_upload import CreateJobFromUrlResponse
|
|
1480
|
+
|
|
1481
|
+
response = CreateJobFromUrlResponse(
|
|
1482
|
+
status="success",
|
|
1483
|
+
job_id="test123",
|
|
1484
|
+
message="Test message",
|
|
1485
|
+
detected_artist="Test Artist",
|
|
1486
|
+
detected_title="Test Song",
|
|
1487
|
+
server_version="1.0.0"
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
assert response.detected_artist == "Test Artist"
|
|
1491
|
+
assert response.detected_title == "Test Song"
|
|
1492
|
+
assert response.server_version == "1.0.0"
|
|
1493
|
+
|
|
1494
|
+
def test_create_job_from_url_request_validation(self):
|
|
1495
|
+
"""Test CreateJobFromUrlRequest validates URL is required."""
|
|
1496
|
+
from backend.api.routes.file_upload import CreateJobFromUrlRequest
|
|
1497
|
+
import pydantic
|
|
1498
|
+
|
|
1499
|
+
# URL is required
|
|
1500
|
+
with pytest.raises(pydantic.ValidationError):
|
|
1501
|
+
CreateJobFromUrlRequest()
|
|
1502
|
+
|
|
1503
|
+
# Valid with just URL
|
|
1504
|
+
request = CreateJobFromUrlRequest(url="https://www.youtube.com/watch?v=abc")
|
|
1505
|
+
assert request.url == "https://www.youtube.com/watch?v=abc"
|
|
1506
|
+
|
|
1507
|
+
def test_validate_url_returns_true_for_all_supported_domains(self):
|
|
1508
|
+
"""Test _validate_url returns True for all supported domains."""
|
|
1509
|
+
from backend.api.routes.file_upload import _validate_url
|
|
1510
|
+
|
|
1511
|
+
# Test various supported domains
|
|
1512
|
+
test_urls = [
|
|
1513
|
+
"https://www.youtube.com/watch?v=abc",
|
|
1514
|
+
"https://youtu.be/abc",
|
|
1515
|
+
"https://music.youtube.com/watch?v=abc",
|
|
1516
|
+
"https://vimeo.com/12345",
|
|
1517
|
+
"https://player.vimeo.com/video/12345",
|
|
1518
|
+
"https://soundcloud.com/artist/track",
|
|
1519
|
+
"https://m.soundcloud.com/artist/track",
|
|
1520
|
+
"https://dailymotion.com/video/abc",
|
|
1521
|
+
"https://facebook.com/video",
|
|
1522
|
+
"https://www.twitch.tv/clips/abc",
|
|
1523
|
+
]
|
|
1524
|
+
|
|
1525
|
+
for url in test_urls:
|
|
1526
|
+
assert _validate_url(url) is True, f"Should accept {url}"
|
|
1527
|
+
|
|
1528
|
+
def test_validate_url_handles_domain_with_port(self):
|
|
1529
|
+
"""Test _validate_url handles URLs with port numbers."""
|
|
1530
|
+
from backend.api.routes.file_upload import _validate_url
|
|
1531
|
+
|
|
1532
|
+
# URL with port should work
|
|
1533
|
+
assert _validate_url("https://youtube.com:443/watch?v=abc") is True
|
|
1534
|
+
assert _validate_url("http://localhost:8080/video") is True
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
class TestUrlBasedJobWorkflow:
|
|
1538
|
+
"""Test the complete URL-based job workflow."""
|
|
1539
|
+
|
|
1540
|
+
def test_job_model_accepts_url(self):
|
|
1541
|
+
"""Test that Job model accepts url field."""
|
|
1542
|
+
from backend.models.job import Job, JobStatus, JobCreate
|
|
1543
|
+
from datetime import datetime, UTC
|
|
1544
|
+
|
|
1545
|
+
job = Job(
|
|
1546
|
+
job_id="test123",
|
|
1547
|
+
status=JobStatus.PENDING,
|
|
1548
|
+
created_at=datetime.now(UTC),
|
|
1549
|
+
updated_at=datetime.now(UTC),
|
|
1550
|
+
url="https://www.youtube.com/watch?v=abc",
|
|
1551
|
+
artist="Test",
|
|
1552
|
+
title="Test"
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
assert job.url == "https://www.youtube.com/watch?v=abc"
|
|
1556
|
+
|
|
1557
|
+
def test_job_create_accepts_url(self):
|
|
1558
|
+
"""Test that JobCreate model accepts url field."""
|
|
1559
|
+
from backend.models.job import JobCreate
|
|
1560
|
+
|
|
1561
|
+
job_create = JobCreate(
|
|
1562
|
+
url="https://www.youtube.com/watch?v=abc",
|
|
1563
|
+
artist="Test Artist",
|
|
1564
|
+
title="Test Song"
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
assert job_create.url == "https://www.youtube.com/watch?v=abc"
|
|
1568
|
+
assert job_create.artist == "Test Artist"
|
|
1569
|
+
assert job_create.title == "Test Song"
|
|
1570
|
+
|
|
1571
|
+
def test_job_create_url_and_file_mutually_exclusive_behavior(self):
|
|
1572
|
+
"""Test that JobCreate allows either URL or filename."""
|
|
1573
|
+
from backend.models.job import JobCreate
|
|
1574
|
+
|
|
1575
|
+
# URL only - valid
|
|
1576
|
+
job1 = JobCreate(url="https://youtube.com/watch?v=abc")
|
|
1577
|
+
assert job1.url is not None
|
|
1578
|
+
assert job1.filename is None
|
|
1579
|
+
|
|
1580
|
+
# Filename only - valid
|
|
1581
|
+
job2 = JobCreate(filename="test.mp3", artist="Test", title="Test")
|
|
1582
|
+
assert job2.filename == "test.mp3"
|
|
1583
|
+
assert job2.url is None
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
class TestIsUrlFunction:
|
|
1587
|
+
"""Test the is_url function from cli_args."""
|
|
1588
|
+
|
|
1589
|
+
def test_is_url_http(self):
|
|
1590
|
+
"""Test that http URLs are detected."""
|
|
1591
|
+
from karaoke_gen.utils.cli_args import is_url
|
|
1592
|
+
|
|
1593
|
+
assert is_url("http://example.com") is True
|
|
1594
|
+
|
|
1595
|
+
def test_is_url_https(self):
|
|
1596
|
+
"""Test that https URLs are detected."""
|
|
1597
|
+
from karaoke_gen.utils.cli_args import is_url
|
|
1598
|
+
|
|
1599
|
+
assert is_url("https://www.youtube.com/watch?v=abc") is True
|
|
1600
|
+
|
|
1601
|
+
def test_is_url_not_url(self):
|
|
1602
|
+
"""Test that non-URLs are not detected."""
|
|
1603
|
+
from karaoke_gen.utils.cli_args import is_url
|
|
1604
|
+
|
|
1605
|
+
assert is_url("/path/to/file.mp3") is False
|
|
1606
|
+
assert is_url("file.mp3") is False
|
|
1607
|
+
assert is_url("") is False
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
class TestUploadEndpointThemeSupport:
|
|
1611
|
+
"""Test that /jobs/upload endpoint supports theme configuration.
|
|
1612
|
+
|
|
1613
|
+
CRITICAL: These tests verify that the /api/jobs/upload endpoint correctly handles
|
|
1614
|
+
theme_id and color_overrides parameters, ensuring preview videos have themed
|
|
1615
|
+
backgrounds instead of black backgrounds.
|
|
1616
|
+
|
|
1617
|
+
This addresses a bug where:
|
|
1618
|
+
- The frontend sends theme_id and color_overrides when uploading files
|
|
1619
|
+
- But the backend /api/jobs/upload endpoint was ignoring these parameters
|
|
1620
|
+
- Result: Jobs created via file upload had black backgrounds instead of themed ones
|
|
1621
|
+
"""
|
|
1622
|
+
|
|
1623
|
+
def test_upload_endpoint_accepts_theme_id_parameter(self):
|
|
1624
|
+
"""Verify the upload endpoint has theme_id as a form parameter.
|
|
1625
|
+
|
|
1626
|
+
CRITICAL: The frontend sends theme_id when uploading files with a theme.
|
|
1627
|
+
If this parameter is missing from the endpoint, the theme is silently ignored
|
|
1628
|
+
and preview videos will have black backgrounds instead of themed ones.
|
|
1629
|
+
"""
|
|
1630
|
+
from backend.api.routes import file_upload as file_upload_module
|
|
1631
|
+
|
|
1632
|
+
with open(file_upload_module.__file__, 'r') as f:
|
|
1633
|
+
source_code = f.read()
|
|
1634
|
+
|
|
1635
|
+
has_theme_id_param = 'theme_id: Optional[str] = Form(' in source_code
|
|
1636
|
+
|
|
1637
|
+
assert has_theme_id_param, (
|
|
1638
|
+
"file_upload.py /jobs/upload endpoint does not have theme_id as a Form parameter. "
|
|
1639
|
+
"The frontend sends theme_id when uploading files with a theme, but the backend "
|
|
1640
|
+
"ignores it. Add: theme_id: Optional[str] = Form(None, description='Theme ID...')"
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
def test_upload_endpoint_accepts_color_overrides_parameter(self):
|
|
1644
|
+
"""Verify the upload endpoint has color_overrides as a form parameter."""
|
|
1645
|
+
from backend.api.routes import file_upload as file_upload_module
|
|
1646
|
+
|
|
1647
|
+
with open(file_upload_module.__file__, 'r') as f:
|
|
1648
|
+
source_code = f.read()
|
|
1649
|
+
|
|
1650
|
+
has_color_overrides_param = 'color_overrides: Optional[str] = Form(' in source_code
|
|
1651
|
+
|
|
1652
|
+
assert has_color_overrides_param, (
|
|
1653
|
+
"file_upload.py /jobs/upload endpoint does not have color_overrides as a Form parameter. "
|
|
1654
|
+
"The frontend sends color_overrides when customizing theme colors."
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
def test_upload_endpoint_calls_prepare_theme_for_job(self):
|
|
1658
|
+
"""Verify the upload endpoint calls _prepare_theme_for_job when theme_id is set.
|
|
1659
|
+
|
|
1660
|
+
CRITICAL: When a job is created via the upload endpoint with a theme_id
|
|
1661
|
+
(and no custom style files), the code must call _prepare_theme_for_job() to set:
|
|
1662
|
+
1. style_params_gcs_path (pointing to the copied style_params.json)
|
|
1663
|
+
2. style_assets (populated with asset mappings)
|
|
1664
|
+
|
|
1665
|
+
Without this, LyricsTranscriber won't have access to the theme's styles
|
|
1666
|
+
and preview videos will have black backgrounds instead of themed ones.
|
|
1667
|
+
"""
|
|
1668
|
+
from backend.api.routes import file_upload as file_upload_module
|
|
1669
|
+
|
|
1670
|
+
with open(file_upload_module.__file__, 'r') as f:
|
|
1671
|
+
source_code = f.read()
|
|
1672
|
+
|
|
1673
|
+
# The function should be called in upload_and_create_job
|
|
1674
|
+
has_theme_prep_call = '_prepare_theme_for_job(' in source_code
|
|
1675
|
+
|
|
1676
|
+
assert has_theme_prep_call, (
|
|
1677
|
+
"file_upload.py does not call _prepare_theme_for_job(). "
|
|
1678
|
+
"When theme_id is provided to /jobs/upload without custom style files, "
|
|
1679
|
+
"the endpoint MUST call _prepare_theme_for_job() to copy the theme's "
|
|
1680
|
+
"style_params.json to the job folder."
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
def test_upload_endpoint_uses_resolve_cdg_txt_defaults(self):
|
|
1684
|
+
"""Verify the upload endpoint uses _resolve_cdg_txt_defaults for theme-based defaults."""
|
|
1685
|
+
from backend.api.routes import file_upload as file_upload_module
|
|
1686
|
+
|
|
1687
|
+
with open(file_upload_module.__file__, 'r') as f:
|
|
1688
|
+
source_code = f.read()
|
|
1689
|
+
|
|
1690
|
+
has_resolve_call = '_resolve_cdg_txt_defaults(' in source_code
|
|
1691
|
+
|
|
1692
|
+
assert has_resolve_call, (
|
|
1693
|
+
"file_upload.py does not call _resolve_cdg_txt_defaults(). "
|
|
1694
|
+
"When theme_id is set, enable_cdg and enable_txt should default to True."
|
|
1695
|
+
)
|
|
1696
|
+
|
|
1697
|
+
def test_upload_endpoint_has_optional_cdg_txt_params(self):
|
|
1698
|
+
"""Verify enable_cdg and enable_txt are Optional[bool] to support theme defaults.
|
|
1699
|
+
|
|
1700
|
+
CRITICAL: If enable_cdg/enable_txt are bool instead of Optional[bool],
|
|
1701
|
+
they will default to False and override the theme-based defaults.
|
|
1702
|
+
"""
|
|
1703
|
+
from backend.api.routes import file_upload as file_upload_module
|
|
1704
|
+
|
|
1705
|
+
with open(file_upload_module.__file__, 'r') as f:
|
|
1706
|
+
source_code = f.read()
|
|
1707
|
+
|
|
1708
|
+
has_optional_cdg = 'enable_cdg: Optional[bool] = Form(' in source_code
|
|
1709
|
+
has_optional_txt = 'enable_txt: Optional[bool] = Form(' in source_code
|
|
1710
|
+
|
|
1711
|
+
assert has_optional_cdg, (
|
|
1712
|
+
"file_upload.py has enable_cdg as bool instead of Optional[bool]. "
|
|
1713
|
+
"This prevents theme-based defaults from working."
|
|
1714
|
+
)
|
|
1715
|
+
assert has_optional_txt, (
|
|
1716
|
+
"file_upload.py has enable_txt as bool instead of Optional[bool]. "
|
|
1717
|
+
"This prevents theme-based defaults from working."
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
def test_job_create_includes_theme_id_and_color_overrides(self):
|
|
1721
|
+
"""Verify JobCreate is called with theme_id and color_overrides."""
|
|
1722
|
+
from backend.api.routes import file_upload as file_upload_module
|
|
1723
|
+
|
|
1724
|
+
with open(file_upload_module.__file__, 'r') as f:
|
|
1725
|
+
source_code = f.read()
|
|
1726
|
+
|
|
1727
|
+
has_theme_id_in_job_create = 'theme_id=theme_id,' in source_code
|
|
1728
|
+
has_color_overrides_in_job_create = 'color_overrides=parsed_color_overrides' in source_code
|
|
1729
|
+
|
|
1730
|
+
assert has_theme_id_in_job_create, (
|
|
1731
|
+
"file_upload.py does not pass theme_id to JobCreate."
|
|
1732
|
+
)
|
|
1733
|
+
assert has_color_overrides_in_job_create, (
|
|
1734
|
+
"file_upload.py does not pass color_overrides to JobCreate."
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
if __name__ == "__main__":
|
|
1739
|
+
pytest.main([__file__, "-v"])
|