karaoke-gen 0.86.7__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.
Files changed (188) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.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"])