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,1053 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-End CLI + Backend Integration Tests
|
|
3
|
+
|
|
4
|
+
These tests verify the FULL flow from CLI to Backend with real emulators.
|
|
5
|
+
They would have caught bugs like:
|
|
6
|
+
- Content-type mismatch in signed URL uploads (403 errors)
|
|
7
|
+
- Missing auth headers in download requests
|
|
8
|
+
- YouTube description field name mismatch
|
|
9
|
+
|
|
10
|
+
Run with: ./scripts/run-emulator-tests.sh
|
|
11
|
+
|
|
12
|
+
Prerequisites:
|
|
13
|
+
- GCS emulator running (fake-gcs-server)
|
|
14
|
+
- Firestore emulator running
|
|
15
|
+
"""
|
|
16
|
+
import pytest
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import tempfile
|
|
20
|
+
import logging
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from unittest.mock import patch, MagicMock, AsyncMock
|
|
23
|
+
|
|
24
|
+
from .conftest import emulators_running
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Skip all tests if emulators aren't running
|
|
28
|
+
pytestmark = pytest.mark.skipif(
|
|
29
|
+
not emulators_running(),
|
|
30
|
+
reason="GCP emulators not running. Start with: scripts/start-emulators.sh"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def test_style_files(tmp_path):
|
|
36
|
+
"""Create realistic test style files for upload testing."""
|
|
37
|
+
# Create a style params JSON that references local files
|
|
38
|
+
style_params = {
|
|
39
|
+
"intro": {
|
|
40
|
+
"background_color": "#000000",
|
|
41
|
+
"background_image": str(tmp_path / "intro_bg.png"),
|
|
42
|
+
"font": str(tmp_path / "test_font.ttf"),
|
|
43
|
+
"title_color": "#ffffff",
|
|
44
|
+
"artist_color": "#ffdf6b"
|
|
45
|
+
},
|
|
46
|
+
"karaoke": {
|
|
47
|
+
"background_color": "#000000",
|
|
48
|
+
"background_image": str(tmp_path / "karaoke_bg.png"),
|
|
49
|
+
"font_path": str(tmp_path / "test_font.ttf"),
|
|
50
|
+
"primary_color": "112, 112, 247, 255",
|
|
51
|
+
"secondary_color": "255, 255, 255, 255"
|
|
52
|
+
},
|
|
53
|
+
"end": {
|
|
54
|
+
"background_color": "#000000",
|
|
55
|
+
"background_image": str(tmp_path / "end_bg.png"),
|
|
56
|
+
"font": str(tmp_path / "test_font.ttf")
|
|
57
|
+
},
|
|
58
|
+
"cdg": {
|
|
59
|
+
"background_color": "#000000",
|
|
60
|
+
"instrumental_background": str(tmp_path / "cdg_inst_bg.png"),
|
|
61
|
+
"title_screen_background": str(tmp_path / "cdg_title_bg.png"),
|
|
62
|
+
"outro_background": str(tmp_path / "cdg_outro_bg.png"),
|
|
63
|
+
"font_path": str(tmp_path / "test_font.ttf")
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
style_params_path = tmp_path / "karaoke-prep-styles.json"
|
|
68
|
+
with open(style_params_path, 'w') as f:
|
|
69
|
+
json.dump(style_params, f)
|
|
70
|
+
|
|
71
|
+
# Create minimal valid PNG (1x1 pixel)
|
|
72
|
+
minimal_png = bytes([
|
|
73
|
+
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, # PNG signature
|
|
74
|
+
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, # IHDR chunk
|
|
75
|
+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, # 1x1
|
|
76
|
+
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
|
77
|
+
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, # IDAT chunk
|
|
78
|
+
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xFF, 0xFF, 0x3F,
|
|
79
|
+
0x00, 0x05, 0xFE, 0x02, 0xFE, 0xDC, 0xCC, 0x59,
|
|
80
|
+
0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, # IEND chunk
|
|
81
|
+
0x44, 0xAE, 0x42, 0x60, 0x82
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
# Create all referenced image files
|
|
85
|
+
for name in ["intro_bg.png", "karaoke_bg.png", "end_bg.png",
|
|
86
|
+
"cdg_inst_bg.png", "cdg_title_bg.png", "cdg_outro_bg.png"]:
|
|
87
|
+
(tmp_path / name).write_bytes(minimal_png)
|
|
88
|
+
|
|
89
|
+
# Create minimal TTF font file
|
|
90
|
+
minimal_ttf = bytes([0x00, 0x01, 0x00, 0x00] + [0x00] * 100)
|
|
91
|
+
(tmp_path / "test_font.ttf").write_bytes(minimal_ttf)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
'style_params_path': str(style_params_path),
|
|
95
|
+
'tmp_path': tmp_path
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.fixture
|
|
100
|
+
def youtube_description():
|
|
101
|
+
"""Sample YouTube description template."""
|
|
102
|
+
return """This is a karaoke (instrumental) version of the song.
|
|
103
|
+
|
|
104
|
+
Created using AI-powered vocal removal.
|
|
105
|
+
|
|
106
|
+
LINKS:
|
|
107
|
+
- Community: https://discord.gg/example
|
|
108
|
+
- More karaoke: https://example.com
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestStyleFileUploadE2E:
|
|
113
|
+
"""
|
|
114
|
+
Test the complete style file upload flow.
|
|
115
|
+
|
|
116
|
+
This is the flow that broke with the content-type mismatch bug:
|
|
117
|
+
1. CLI parses style_params.json and finds all referenced files
|
|
118
|
+
2. CLI sends metadata to backend, gets signed upload URLs
|
|
119
|
+
3. CLI uploads each file to its signed URL with correct content-type
|
|
120
|
+
4. Backend records the GCS paths for later use
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def test_style_params_json_upload_content_type(self, client, auth_headers, tmp_path):
|
|
124
|
+
"""
|
|
125
|
+
Test that style_params.json uploads with application/json content-type.
|
|
126
|
+
|
|
127
|
+
BUG CAUGHT: CLI was deriving content-type from Path('.json').suffix which
|
|
128
|
+
returns '' because '.json' looks like a hidden file. This caused 403 errors
|
|
129
|
+
because the upload content-type didn't match the signed URL's expected type.
|
|
130
|
+
"""
|
|
131
|
+
# Create minimal style params
|
|
132
|
+
style_params = {"karaoke": {"background_color": "#000000"}}
|
|
133
|
+
style_path = tmp_path / "styles.json"
|
|
134
|
+
with open(style_path, 'w') as f:
|
|
135
|
+
json.dump(style_params, f)
|
|
136
|
+
|
|
137
|
+
# Create job with style files
|
|
138
|
+
response = client.post(
|
|
139
|
+
"/api/audio-search/search",
|
|
140
|
+
headers=auth_headers,
|
|
141
|
+
json={
|
|
142
|
+
'artist': 'Test Artist',
|
|
143
|
+
'title': 'Test Song',
|
|
144
|
+
'auto_download': False,
|
|
145
|
+
'files': [
|
|
146
|
+
{
|
|
147
|
+
'filename': 'styles.json',
|
|
148
|
+
'content_type': 'application/json',
|
|
149
|
+
'file_type': 'style_params'
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Should not fail with 500 (backend error)
|
|
156
|
+
assert response.status_code in [200, 404], f"Unexpected: {response.status_code} - {response.text}"
|
|
157
|
+
|
|
158
|
+
if response.status_code == 200:
|
|
159
|
+
data = response.json()
|
|
160
|
+
# Verify upload URLs are returned
|
|
161
|
+
if 'upload_urls' in data:
|
|
162
|
+
assert 'style_params' in data['upload_urls']
|
|
163
|
+
# The URL should be a signed GCS URL
|
|
164
|
+
url = data['upload_urls']['style_params']
|
|
165
|
+
assert 'storage' in url.lower() or 'localhost' in url.lower()
|
|
166
|
+
|
|
167
|
+
def test_image_upload_content_type(self, client, auth_headers, tmp_path):
|
|
168
|
+
"""Test that PNG images upload with image/png content-type."""
|
|
169
|
+
response = client.post(
|
|
170
|
+
"/api/audio-search/search",
|
|
171
|
+
headers=auth_headers,
|
|
172
|
+
json={
|
|
173
|
+
'artist': 'Test Artist',
|
|
174
|
+
'title': 'Test Song',
|
|
175
|
+
'auto_download': False,
|
|
176
|
+
'files': [
|
|
177
|
+
{
|
|
178
|
+
'filename': 'background.png',
|
|
179
|
+
'content_type': 'image/png',
|
|
180
|
+
'file_type': 'style_intro_background'
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assert response.status_code in [200, 404]
|
|
187
|
+
|
|
188
|
+
if response.status_code == 200:
|
|
189
|
+
data = response.json()
|
|
190
|
+
if 'upload_urls' in data:
|
|
191
|
+
assert 'style_intro_background' in data['upload_urls']
|
|
192
|
+
|
|
193
|
+
def test_font_upload_content_type(self, client, auth_headers):
|
|
194
|
+
"""Test that TTF fonts upload with font/ttf content-type."""
|
|
195
|
+
response = client.post(
|
|
196
|
+
"/api/audio-search/search",
|
|
197
|
+
headers=auth_headers,
|
|
198
|
+
json={
|
|
199
|
+
'artist': 'Test Artist',
|
|
200
|
+
'title': 'Test Song',
|
|
201
|
+
'auto_download': False,
|
|
202
|
+
'files': [
|
|
203
|
+
{
|
|
204
|
+
'filename': 'font.ttf',
|
|
205
|
+
'content_type': 'font/ttf',
|
|
206
|
+
'file_type': 'style_font'
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
assert response.status_code in [200, 404]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestYouTubeDescriptionFieldMapping:
|
|
216
|
+
"""
|
|
217
|
+
Test that YouTube description is properly passed from API to workers.
|
|
218
|
+
|
|
219
|
+
BUG CAUGHT: audio_search endpoint set 'youtube_description' but
|
|
220
|
+
video_worker.py reads 'youtube_description_template'. YouTube uploads
|
|
221
|
+
silently failed because the template field was always None.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def test_job_has_youtube_description_template(self, client, auth_headers, youtube_description):
|
|
225
|
+
"""
|
|
226
|
+
Test that when youtube_description is provided, youtube_description_template is also set.
|
|
227
|
+
|
|
228
|
+
This is critical because video_worker.py uses this pattern:
|
|
229
|
+
if youtube_credentials and getattr(job, 'youtube_description_template', None):
|
|
230
|
+
|
|
231
|
+
NOTE: We test with enable_youtube_upload=False to avoid credentials validation.
|
|
232
|
+
The important thing is that when youtube_description is provided, both fields are set.
|
|
233
|
+
"""
|
|
234
|
+
response = client.post(
|
|
235
|
+
"/api/audio-search/search",
|
|
236
|
+
headers=auth_headers,
|
|
237
|
+
json={
|
|
238
|
+
'artist': 'Test Artist',
|
|
239
|
+
'title': 'Test Song',
|
|
240
|
+
'auto_download': False,
|
|
241
|
+
'enable_youtube_upload': False, # Don't require YouTube credentials
|
|
242
|
+
'youtube_description': youtube_description # But still provide description
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Job should be created (or 404 if flacfetch not configured)
|
|
247
|
+
assert response.status_code in [200, 404], f"Unexpected: {response.status_code}"
|
|
248
|
+
|
|
249
|
+
if response.status_code == 200:
|
|
250
|
+
data = response.json()
|
|
251
|
+
job_id = data.get('job_id')
|
|
252
|
+
|
|
253
|
+
if job_id:
|
|
254
|
+
# Fetch the job and verify both fields are set
|
|
255
|
+
time.sleep(0.2) # Allow for emulator consistency
|
|
256
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
257
|
+
|
|
258
|
+
if job_response.status_code == 200:
|
|
259
|
+
job = job_response.json()
|
|
260
|
+
|
|
261
|
+
# CRITICAL: Both fields must be set for YouTube upload to work
|
|
262
|
+
assert job.get('youtube_description') == youtube_description, \
|
|
263
|
+
"youtube_description not set correctly"
|
|
264
|
+
assert job.get('youtube_description_template') == youtube_description, \
|
|
265
|
+
"youtube_description_template not set - YouTube upload will fail!"
|
|
266
|
+
|
|
267
|
+
def test_youtube_upload_disabled_no_template_needed(self, client, auth_headers):
|
|
268
|
+
"""Test that youtube_description_template is not required when upload is disabled."""
|
|
269
|
+
response = client.post(
|
|
270
|
+
"/api/audio-search/search",
|
|
271
|
+
headers=auth_headers,
|
|
272
|
+
json={
|
|
273
|
+
'artist': 'Test Artist',
|
|
274
|
+
'title': 'Test Song',
|
|
275
|
+
'auto_download': False,
|
|
276
|
+
'enable_youtube_upload': False
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Should succeed without YouTube config
|
|
281
|
+
assert response.status_code in [200, 404]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class TestDownloadAuthHeaders:
|
|
285
|
+
"""
|
|
286
|
+
Test that file downloads include authentication headers.
|
|
287
|
+
|
|
288
|
+
BUG CAUGHT: download_file_via_url() used requests.get() directly instead
|
|
289
|
+
of self.session.get(), so auth headers were not included. All downloads
|
|
290
|
+
failed with 401/403 even though the job completed successfully.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
def test_download_endpoint_requires_auth(self, client):
|
|
294
|
+
"""Test that download endpoints reject unauthenticated requests."""
|
|
295
|
+
# Try to download without auth header
|
|
296
|
+
response = client.get("/api/jobs/nonexistent/download-urls")
|
|
297
|
+
|
|
298
|
+
# Should fail with 401 (unauthorized), not 404
|
|
299
|
+
assert response.status_code in [401, 403], \
|
|
300
|
+
f"Download endpoint should require auth, got {response.status_code}"
|
|
301
|
+
|
|
302
|
+
def test_download_endpoint_with_auth(self, client, auth_headers):
|
|
303
|
+
"""Test that download endpoints work with auth header (and reject without).
|
|
304
|
+
|
|
305
|
+
This tests that:
|
|
306
|
+
1. Auth is required (401/403 without header)
|
|
307
|
+
2. Auth works (not 401/403 with header)
|
|
308
|
+
|
|
309
|
+
Note: A 400 "Job not complete" is acceptable since we're testing auth, not completion.
|
|
310
|
+
"""
|
|
311
|
+
# First create a job via the simple /api/jobs endpoint (no YouTube validation)
|
|
312
|
+
create_response = client.post(
|
|
313
|
+
"/api/jobs",
|
|
314
|
+
headers=auth_headers,
|
|
315
|
+
json={
|
|
316
|
+
"url": "https://youtube.com/watch?v=test123",
|
|
317
|
+
"artist": "Test Artist",
|
|
318
|
+
"title": "Test Song"
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if create_response.status_code == 200:
|
|
323
|
+
job_id = create_response.json()["job_id"]
|
|
324
|
+
time.sleep(0.2)
|
|
325
|
+
|
|
326
|
+
# Try to get download URLs with auth
|
|
327
|
+
download_response = client.get(
|
|
328
|
+
f"/api/jobs/{job_id}/download-urls",
|
|
329
|
+
headers=auth_headers
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Should NOT be 401/403 (auth failure) - we're testing that auth header works
|
|
333
|
+
# 400 (job not complete) is acceptable - that's a business logic error, not auth
|
|
334
|
+
# 200 would mean job has files ready, which is unlikely in this test
|
|
335
|
+
assert download_response.status_code not in [401, 403], \
|
|
336
|
+
f"Download URLs failed with auth error: {download_response.status_code} - {download_response.text}"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class TestFullAudioSearchFlow:
|
|
340
|
+
"""
|
|
341
|
+
Test the complete audio search flow that the CLI uses.
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
def test_audio_search_creates_job_with_all_fields(
|
|
345
|
+
self, client, auth_headers, test_style_files, youtube_description
|
|
346
|
+
):
|
|
347
|
+
"""Test that audio search creates a job with all expected fields.
|
|
348
|
+
|
|
349
|
+
NOTE: We set enable_youtube_upload=False to avoid credentials validation
|
|
350
|
+
in the emulator environment. The field mapping test is covered separately.
|
|
351
|
+
"""
|
|
352
|
+
response = client.post(
|
|
353
|
+
"/api/audio-search/search",
|
|
354
|
+
headers=auth_headers,
|
|
355
|
+
json={
|
|
356
|
+
'artist': 'ABBA',
|
|
357
|
+
'title': 'Waterloo',
|
|
358
|
+
'auto_download': False,
|
|
359
|
+
'enable_cdg': True,
|
|
360
|
+
'enable_txt': True,
|
|
361
|
+
'enable_youtube_upload': False, # Don't require YouTube credentials
|
|
362
|
+
'youtube_description': youtube_description,
|
|
363
|
+
'brand_prefix': 'TEST',
|
|
364
|
+
'files': [
|
|
365
|
+
{
|
|
366
|
+
'filename': 'styles.json',
|
|
367
|
+
'content_type': 'application/json',
|
|
368
|
+
'file_type': 'style_params'
|
|
369
|
+
}
|
|
370
|
+
]
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Should create job or return 404 if no results
|
|
375
|
+
assert response.status_code in [200, 404], \
|
|
376
|
+
f"Unexpected status: {response.status_code} - {response.text}"
|
|
377
|
+
|
|
378
|
+
if response.status_code == 200:
|
|
379
|
+
data = response.json()
|
|
380
|
+
|
|
381
|
+
# Verify job was created
|
|
382
|
+
assert 'job_id' in data
|
|
383
|
+
job_id = data['job_id']
|
|
384
|
+
|
|
385
|
+
# Verify upload URLs returned for style files
|
|
386
|
+
if 'upload_urls' in data:
|
|
387
|
+
assert isinstance(data['upload_urls'], dict)
|
|
388
|
+
|
|
389
|
+
# Fetch job and verify all fields are set
|
|
390
|
+
time.sleep(0.2)
|
|
391
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
392
|
+
|
|
393
|
+
if job_response.status_code == 200:
|
|
394
|
+
job = job_response.json()
|
|
395
|
+
|
|
396
|
+
# Verify job configuration
|
|
397
|
+
assert job.get('enable_cdg') is True
|
|
398
|
+
assert job.get('enable_txt') is True
|
|
399
|
+
assert job.get('youtube_description') == youtube_description
|
|
400
|
+
# CRITICAL: This field must be set for video_worker.py
|
|
401
|
+
assert job.get('youtube_description_template') == youtube_description
|
|
402
|
+
|
|
403
|
+
def test_audio_search_returns_server_version(self, client, auth_headers):
|
|
404
|
+
"""Test that audio search response includes server version."""
|
|
405
|
+
response = client.post(
|
|
406
|
+
"/api/audio-search/search",
|
|
407
|
+
headers=auth_headers,
|
|
408
|
+
json={
|
|
409
|
+
'artist': 'Test',
|
|
410
|
+
'title': 'Song',
|
|
411
|
+
'auto_download': False
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if response.status_code == 200:
|
|
416
|
+
data = response.json()
|
|
417
|
+
# Server version helps CLI verify compatibility
|
|
418
|
+
assert 'server_version' in data
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class TestDistributionSettings:
|
|
422
|
+
"""
|
|
423
|
+
Test that distribution settings are properly passed through the entire flow.
|
|
424
|
+
|
|
425
|
+
CRITICAL: These tests verify that brand_prefix, dropbox_path, gdrive_folder_id,
|
|
426
|
+
and discord_webhook_url are correctly propagated from:
|
|
427
|
+
1. CLI parameters → Audio Search API request
|
|
428
|
+
2. Audio Search API → JobCreate model
|
|
429
|
+
3. JobCreate → Job (in Firestore)
|
|
430
|
+
4. Job → video_worker (for KaraokeFinalise and native uploads)
|
|
431
|
+
|
|
432
|
+
BUG CAUGHT (v0.75.55): job_manager.create_job() was NOT passing these fields
|
|
433
|
+
from JobCreate to Job, causing all distribution uploads to silently fail.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def test_brand_prefix_passed_to_job(self, client, auth_headers):
|
|
437
|
+
"""Test that brand_prefix is stored in the created job."""
|
|
438
|
+
response = client.post(
|
|
439
|
+
"/api/audio-search/search",
|
|
440
|
+
headers=auth_headers,
|
|
441
|
+
json={
|
|
442
|
+
'artist': 'Test Artist',
|
|
443
|
+
'title': 'Test Song',
|
|
444
|
+
'auto_download': False,
|
|
445
|
+
'brand_prefix': 'NOMAD',
|
|
446
|
+
'enable_youtube_upload': False
|
|
447
|
+
}
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if response.status_code == 200:
|
|
451
|
+
data = response.json()
|
|
452
|
+
job_id = data.get('job_id')
|
|
453
|
+
|
|
454
|
+
if job_id:
|
|
455
|
+
time.sleep(0.2)
|
|
456
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
457
|
+
|
|
458
|
+
if job_response.status_code == 200:
|
|
459
|
+
job = job_response.json()
|
|
460
|
+
assert job.get('brand_prefix') == 'NOMAD', \
|
|
461
|
+
"brand_prefix not passed to job - Dropbox upload will fail!"
|
|
462
|
+
|
|
463
|
+
def test_dropbox_path_passed_to_job(self, client, auth_headers):
|
|
464
|
+
"""Test that dropbox_path is stored in the created job."""
|
|
465
|
+
response = client.post(
|
|
466
|
+
"/api/audio-search/search",
|
|
467
|
+
headers=auth_headers,
|
|
468
|
+
json={
|
|
469
|
+
'artist': 'Test Artist',
|
|
470
|
+
'title': 'Test Song',
|
|
471
|
+
'auto_download': False,
|
|
472
|
+
'dropbox_path': '/Karaoke/Tracks-Organized',
|
|
473
|
+
'enable_youtube_upload': False
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if response.status_code == 200:
|
|
478
|
+
data = response.json()
|
|
479
|
+
job_id = data.get('job_id')
|
|
480
|
+
|
|
481
|
+
if job_id:
|
|
482
|
+
time.sleep(0.2)
|
|
483
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
484
|
+
|
|
485
|
+
if job_response.status_code == 200:
|
|
486
|
+
job = job_response.json()
|
|
487
|
+
assert job.get('dropbox_path') == '/Karaoke/Tracks-Organized', \
|
|
488
|
+
"dropbox_path not passed to job - Dropbox upload will fail!"
|
|
489
|
+
|
|
490
|
+
def test_gdrive_folder_id_passed_to_job(self, client, auth_headers):
|
|
491
|
+
"""Test that gdrive_folder_id is stored in the created job."""
|
|
492
|
+
response = client.post(
|
|
493
|
+
"/api/audio-search/search",
|
|
494
|
+
headers=auth_headers,
|
|
495
|
+
json={
|
|
496
|
+
'artist': 'Test Artist',
|
|
497
|
+
'title': 'Test Song',
|
|
498
|
+
'auto_download': False,
|
|
499
|
+
'gdrive_folder_id': '1abc123xyz',
|
|
500
|
+
'enable_youtube_upload': False
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if response.status_code == 200:
|
|
505
|
+
data = response.json()
|
|
506
|
+
job_id = data.get('job_id')
|
|
507
|
+
|
|
508
|
+
if job_id:
|
|
509
|
+
time.sleep(0.2)
|
|
510
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
511
|
+
|
|
512
|
+
if job_response.status_code == 200:
|
|
513
|
+
job = job_response.json()
|
|
514
|
+
assert job.get('gdrive_folder_id') == '1abc123xyz', \
|
|
515
|
+
"gdrive_folder_id not passed to job - Google Drive upload will fail!"
|
|
516
|
+
|
|
517
|
+
def test_discord_webhook_url_passed_to_job(self, client, auth_headers):
|
|
518
|
+
"""Test that discord_webhook_url is stored in the created job."""
|
|
519
|
+
webhook_url = 'https://discord.com/api/webhooks/123/abc'
|
|
520
|
+
response = client.post(
|
|
521
|
+
"/api/audio-search/search",
|
|
522
|
+
headers=auth_headers,
|
|
523
|
+
json={
|
|
524
|
+
'artist': 'Test Artist',
|
|
525
|
+
'title': 'Test Song',
|
|
526
|
+
'auto_download': False,
|
|
527
|
+
'discord_webhook_url': webhook_url,
|
|
528
|
+
'enable_youtube_upload': False
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
if response.status_code == 200:
|
|
533
|
+
data = response.json()
|
|
534
|
+
job_id = data.get('job_id')
|
|
535
|
+
|
|
536
|
+
if job_id:
|
|
537
|
+
time.sleep(0.2)
|
|
538
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
539
|
+
|
|
540
|
+
if job_response.status_code == 200:
|
|
541
|
+
job = job_response.json()
|
|
542
|
+
assert job.get('discord_webhook_url') == webhook_url, \
|
|
543
|
+
"discord_webhook_url not passed to job - Discord notification will fail!"
|
|
544
|
+
|
|
545
|
+
def test_all_distribution_settings_together(self, client, auth_headers, youtube_description):
|
|
546
|
+
"""
|
|
547
|
+
Test that ALL distribution settings are passed together.
|
|
548
|
+
|
|
549
|
+
This is the full integration test that mirrors what the real CLI does.
|
|
550
|
+
"""
|
|
551
|
+
response = client.post(
|
|
552
|
+
"/api/audio-search/search",
|
|
553
|
+
headers=auth_headers,
|
|
554
|
+
json={
|
|
555
|
+
'artist': 'ABBA',
|
|
556
|
+
'title': 'Waterloo',
|
|
557
|
+
'auto_download': False,
|
|
558
|
+
'enable_cdg': True,
|
|
559
|
+
'enable_txt': True,
|
|
560
|
+
'brand_prefix': 'NOMAD',
|
|
561
|
+
'dropbox_path': '/Karaoke/Tracks-Organized',
|
|
562
|
+
'gdrive_folder_id': '1abc123xyz',
|
|
563
|
+
'discord_webhook_url': 'https://discord.com/api/webhooks/123/abc',
|
|
564
|
+
'enable_youtube_upload': False,
|
|
565
|
+
'youtube_description': youtube_description,
|
|
566
|
+
}
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if response.status_code == 200:
|
|
570
|
+
data = response.json()
|
|
571
|
+
job_id = data.get('job_id')
|
|
572
|
+
|
|
573
|
+
if job_id:
|
|
574
|
+
time.sleep(0.2)
|
|
575
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
576
|
+
|
|
577
|
+
if job_response.status_code == 200:
|
|
578
|
+
job = job_response.json()
|
|
579
|
+
|
|
580
|
+
# Verify all distribution settings
|
|
581
|
+
errors = []
|
|
582
|
+
if job.get('brand_prefix') != 'NOMAD':
|
|
583
|
+
errors.append("brand_prefix not set")
|
|
584
|
+
if job.get('dropbox_path') != '/Karaoke/Tracks-Organized':
|
|
585
|
+
errors.append("dropbox_path not set")
|
|
586
|
+
if job.get('gdrive_folder_id') != '1abc123xyz':
|
|
587
|
+
errors.append("gdrive_folder_id not set")
|
|
588
|
+
if job.get('discord_webhook_url') != 'https://discord.com/api/webhooks/123/abc':
|
|
589
|
+
errors.append("discord_webhook_url not set")
|
|
590
|
+
if job.get('youtube_description') != youtube_description:
|
|
591
|
+
errors.append("youtube_description not set")
|
|
592
|
+
if job.get('youtube_description_template') != youtube_description:
|
|
593
|
+
errors.append("youtube_description_template not set")
|
|
594
|
+
if job.get('enable_cdg') is not True:
|
|
595
|
+
errors.append("enable_cdg not set")
|
|
596
|
+
if job.get('enable_txt') is not True:
|
|
597
|
+
errors.append("enable_txt not set")
|
|
598
|
+
|
|
599
|
+
assert len(errors) == 0, \
|
|
600
|
+
f"Distribution settings not properly passed: {', '.join(errors)}"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class TestJobModelFileUpload:
|
|
604
|
+
"""
|
|
605
|
+
Test that the /api/file-upload endpoint accepts distribution parameters.
|
|
606
|
+
|
|
607
|
+
This tests the alternative flow where users upload a file directly
|
|
608
|
+
instead of using audio search.
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
def test_file_upload_accepts_distribution_params(self, client, auth_headers):
|
|
612
|
+
"""Test that file upload endpoint accepts all distribution parameters."""
|
|
613
|
+
# Note: This is a POST to create a job with file upload intent
|
|
614
|
+
# The actual file is uploaded separately via signed URL
|
|
615
|
+
response = client.post(
|
|
616
|
+
"/api/jobs",
|
|
617
|
+
headers=auth_headers,
|
|
618
|
+
json={
|
|
619
|
+
'artist': 'Test Artist',
|
|
620
|
+
'title': 'Test Song',
|
|
621
|
+
'url': 'https://example.com/audio.flac',
|
|
622
|
+
'brand_prefix': 'TEST',
|
|
623
|
+
'dropbox_path': '/Test/Path',
|
|
624
|
+
'gdrive_folder_id': 'folder123',
|
|
625
|
+
'discord_webhook_url': 'https://discord.com/webhook/test',
|
|
626
|
+
'enable_cdg': True,
|
|
627
|
+
'enable_txt': True,
|
|
628
|
+
}
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
# Should accept the request (even if validation fails for other reasons)
|
|
632
|
+
assert response.status_code in [200, 400, 422], \
|
|
633
|
+
f"Unexpected status: {response.status_code} - {response.text}"
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class TestOutputFormatSettings:
|
|
637
|
+
"""
|
|
638
|
+
Test that output format settings (CDG, TXT) are properly passed.
|
|
639
|
+
|
|
640
|
+
These settings control which output files are generated:
|
|
641
|
+
- enable_cdg: Generate CDG+MP3 karaoke package
|
|
642
|
+
- enable_txt: Generate TXT lyrics file
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
def test_enable_cdg_passed_to_job(self, client, auth_headers):
|
|
646
|
+
"""Test enable_cdg flag is properly stored."""
|
|
647
|
+
response = client.post(
|
|
648
|
+
"/api/audio-search/search",
|
|
649
|
+
headers=auth_headers,
|
|
650
|
+
json={
|
|
651
|
+
'artist': 'Test',
|
|
652
|
+
'title': 'Song',
|
|
653
|
+
'auto_download': False,
|
|
654
|
+
'enable_cdg': True,
|
|
655
|
+
'enable_youtube_upload': False
|
|
656
|
+
}
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if response.status_code == 200:
|
|
660
|
+
data = response.json()
|
|
661
|
+
job_id = data.get('job_id')
|
|
662
|
+
|
|
663
|
+
if job_id:
|
|
664
|
+
time.sleep(0.2)
|
|
665
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
666
|
+
|
|
667
|
+
if job_response.status_code == 200:
|
|
668
|
+
job = job_response.json()
|
|
669
|
+
assert job.get('enable_cdg') is True
|
|
670
|
+
|
|
671
|
+
def test_enable_txt_passed_to_job(self, client, auth_headers):
|
|
672
|
+
"""Test enable_txt flag is properly stored."""
|
|
673
|
+
response = client.post(
|
|
674
|
+
"/api/audio-search/search",
|
|
675
|
+
headers=auth_headers,
|
|
676
|
+
json={
|
|
677
|
+
'artist': 'Test',
|
|
678
|
+
'title': 'Song',
|
|
679
|
+
'auto_download': False,
|
|
680
|
+
'enable_txt': True,
|
|
681
|
+
'enable_youtube_upload': False
|
|
682
|
+
}
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if response.status_code == 200:
|
|
686
|
+
data = response.json()
|
|
687
|
+
job_id = data.get('job_id')
|
|
688
|
+
|
|
689
|
+
if job_id:
|
|
690
|
+
time.sleep(0.2)
|
|
691
|
+
job_response = client.get(f"/api/jobs/{job_id}", headers=auth_headers)
|
|
692
|
+
|
|
693
|
+
if job_response.status_code == 200:
|
|
694
|
+
job = job_response.json()
|
|
695
|
+
assert job.get('enable_txt') is True
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class TestCLIClientIntegration:
|
|
699
|
+
"""
|
|
700
|
+
Test the actual CLI client code against the backend.
|
|
701
|
+
|
|
702
|
+
These tests import and use the real RemoteKaraokeClient class.
|
|
703
|
+
"""
|
|
704
|
+
|
|
705
|
+
@pytest.fixture
|
|
706
|
+
def cli_client(self, tmp_path):
|
|
707
|
+
"""Create a CLI client configured for local testing."""
|
|
708
|
+
# Import here to avoid issues when emulators aren't running
|
|
709
|
+
from karaoke_gen.utils.remote_cli import RemoteKaraokeClient, Config
|
|
710
|
+
|
|
711
|
+
logger = logging.getLogger("test_cli")
|
|
712
|
+
config = Config(
|
|
713
|
+
service_url='http://localhost:8000',
|
|
714
|
+
review_ui_url='http://localhost:3000',
|
|
715
|
+
poll_interval=5,
|
|
716
|
+
output_dir=str(tmp_path / 'output'),
|
|
717
|
+
auth_token='test-admin-token',
|
|
718
|
+
environment='test'
|
|
719
|
+
)
|
|
720
|
+
return RemoteKaraokeClient(config, logger)
|
|
721
|
+
|
|
722
|
+
def test_cli_get_content_type_handles_all_extensions(self, cli_client):
|
|
723
|
+
"""Test CLI content type detection for all file types we use."""
|
|
724
|
+
# JSON
|
|
725
|
+
assert cli_client._get_content_type('/path/to/style.json') == 'application/json'
|
|
726
|
+
# Images
|
|
727
|
+
assert cli_client._get_content_type('/path/to/bg.png') == 'image/png'
|
|
728
|
+
assert cli_client._get_content_type('/path/to/bg.jpg') == 'image/jpeg'
|
|
729
|
+
assert cli_client._get_content_type('/path/to/bg.jpeg') == 'image/jpeg'
|
|
730
|
+
# Fonts
|
|
731
|
+
assert cli_client._get_content_type('/path/to/font.ttf') == 'font/ttf'
|
|
732
|
+
assert cli_client._get_content_type('/path/to/font.otf') == 'font/otf'
|
|
733
|
+
# Audio
|
|
734
|
+
assert cli_client._get_content_type('/path/to/audio.flac') == 'audio/flac'
|
|
735
|
+
assert cli_client._get_content_type('/path/to/audio.mp3') == 'audio/mpeg'
|
|
736
|
+
|
|
737
|
+
def test_cli_download_uses_session_not_requests(self, cli_client, tmp_path):
|
|
738
|
+
"""
|
|
739
|
+
Test that CLI download method uses session (with auth headers).
|
|
740
|
+
|
|
741
|
+
This verifies the fix for the download auth bug.
|
|
742
|
+
"""
|
|
743
|
+
# Mock the session.get to verify it's called
|
|
744
|
+
mock_response = MagicMock()
|
|
745
|
+
mock_response.status_code = 200
|
|
746
|
+
mock_response.iter_content.return_value = [b'test content']
|
|
747
|
+
cli_client.session.get = MagicMock(return_value=mock_response)
|
|
748
|
+
|
|
749
|
+
local_path = str(tmp_path / "test.mp4")
|
|
750
|
+
|
|
751
|
+
# Call download with a relative URL
|
|
752
|
+
result = cli_client.download_file_via_url("/api/jobs/123/download/test", local_path)
|
|
753
|
+
|
|
754
|
+
# Verify session.get was called (not bare requests.get)
|
|
755
|
+
assert result is True
|
|
756
|
+
cli_client.session.get.assert_called_once()
|
|
757
|
+
|
|
758
|
+
# Verify URL was constructed correctly
|
|
759
|
+
call_args = cli_client.session.get.call_args
|
|
760
|
+
assert 'localhost:8000' in call_args[0][0] or cli_client.config.service_url in call_args[0][0]
|
|
761
|
+
|
|
762
|
+
def test_cli_parse_style_params_extracts_all_assets(self, cli_client, test_style_files):
|
|
763
|
+
"""Test that CLI correctly parses style params and finds all asset files."""
|
|
764
|
+
assets = cli_client._parse_style_params(test_style_files['style_params_path'])
|
|
765
|
+
|
|
766
|
+
# Should find all the referenced files
|
|
767
|
+
assert len(assets) > 0
|
|
768
|
+
|
|
769
|
+
# Verify it found background images
|
|
770
|
+
bg_keys = [k for k in assets.keys() if 'background' in k.lower()]
|
|
771
|
+
assert len(bg_keys) > 0, "Should find background image references"
|
|
772
|
+
|
|
773
|
+
# Verify it found font
|
|
774
|
+
font_keys = [k for k in assets.keys() if 'font' in k.lower()]
|
|
775
|
+
assert len(font_keys) > 0, "Should find font references"
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class TestDropboxServiceIntegration:
|
|
779
|
+
"""
|
|
780
|
+
Test Dropbox service brand code calculation logic.
|
|
781
|
+
|
|
782
|
+
These tests verify the brand code calculation algorithm that ensures
|
|
783
|
+
sequential brand codes (e.g., NOMAD-1163, NOMAD-1164).
|
|
784
|
+
|
|
785
|
+
Note: These tests mock the Dropbox SDK since we can't run against
|
|
786
|
+
real Dropbox in CI. The unit tests in test_dropbox_service.py cover
|
|
787
|
+
the SDK interactions in detail.
|
|
788
|
+
"""
|
|
789
|
+
|
|
790
|
+
def test_brand_code_calculation_algorithm(self):
|
|
791
|
+
"""
|
|
792
|
+
Test the brand code calculation pattern matching.
|
|
793
|
+
|
|
794
|
+
This is the core algorithm used by DropboxService.get_next_brand_code()
|
|
795
|
+
to find the highest existing brand code and return the next one.
|
|
796
|
+
"""
|
|
797
|
+
import re
|
|
798
|
+
|
|
799
|
+
# Simulate existing folder names
|
|
800
|
+
existing_folders = [
|
|
801
|
+
"NOMAD-1161 - Artist A - Song A",
|
|
802
|
+
"NOMAD-1162 - Artist B - Song B",
|
|
803
|
+
"NOMAD-1163 - Artist C - Song C",
|
|
804
|
+
"OTHER-0001 - Different Brand",
|
|
805
|
+
"Random Folder",
|
|
806
|
+
"NOMAD-0001 - First Ever",
|
|
807
|
+
]
|
|
808
|
+
|
|
809
|
+
brand_prefix = "NOMAD"
|
|
810
|
+
pattern = re.compile(rf"^{re.escape(brand_prefix)}-(\d{{4}})")
|
|
811
|
+
|
|
812
|
+
max_num = 0
|
|
813
|
+
for folder in existing_folders:
|
|
814
|
+
match = pattern.match(folder)
|
|
815
|
+
if match:
|
|
816
|
+
num = int(match.group(1))
|
|
817
|
+
max_num = max(max_num, num)
|
|
818
|
+
|
|
819
|
+
next_code = f"{brand_prefix}-{max_num + 1:04d}"
|
|
820
|
+
|
|
821
|
+
assert max_num == 1163, "Should find NOMAD-1163 as highest"
|
|
822
|
+
assert next_code == "NOMAD-1164", "Next code should be NOMAD-1164"
|
|
823
|
+
|
|
824
|
+
def test_brand_code_empty_folder(self):
|
|
825
|
+
"""Test brand code starts at 0001 when folder is empty."""
|
|
826
|
+
import re
|
|
827
|
+
|
|
828
|
+
existing_folders = []
|
|
829
|
+
brand_prefix = "NEWBRAND"
|
|
830
|
+
pattern = re.compile(rf"^{re.escape(brand_prefix)}-(\d{{4}})")
|
|
831
|
+
|
|
832
|
+
max_num = 0
|
|
833
|
+
for folder in existing_folders:
|
|
834
|
+
match = pattern.match(folder)
|
|
835
|
+
if match:
|
|
836
|
+
num = int(match.group(1))
|
|
837
|
+
max_num = max(max_num, num)
|
|
838
|
+
|
|
839
|
+
next_code = f"{brand_prefix}-{max_num + 1:04d}"
|
|
840
|
+
|
|
841
|
+
assert next_code == "NEWBRAND-0001"
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
class TestGoogleDriveServiceIntegration:
|
|
845
|
+
"""
|
|
846
|
+
Test Google Drive service folder structure logic.
|
|
847
|
+
|
|
848
|
+
These tests verify the folder structure for public share uploads:
|
|
849
|
+
- MP4/ for 4K lossy videos
|
|
850
|
+
- MP4-720p/ for 720p videos
|
|
851
|
+
- CDG/ for CDG packages
|
|
852
|
+
|
|
853
|
+
Note: These tests verify the logic, not actual Drive API calls.
|
|
854
|
+
"""
|
|
855
|
+
|
|
856
|
+
def test_public_share_folder_structure(self):
|
|
857
|
+
"""
|
|
858
|
+
Test that the correct folder structure is created for public shares.
|
|
859
|
+
|
|
860
|
+
Expected structure:
|
|
861
|
+
root_folder/
|
|
862
|
+
├── MP4/
|
|
863
|
+
│ └── {brand_code} - {artist} - {title}.mp4
|
|
864
|
+
├── MP4-720p/
|
|
865
|
+
│ └── {brand_code} - {artist} - {title}.mp4
|
|
866
|
+
└── CDG/
|
|
867
|
+
└── {brand_code} - {artist} - {title}.zip
|
|
868
|
+
"""
|
|
869
|
+
expected_folders = ["MP4", "MP4-720p", "CDG"]
|
|
870
|
+
|
|
871
|
+
# This mirrors the logic in GoogleDriveService.upload_to_public_share()
|
|
872
|
+
upload_plan = []
|
|
873
|
+
|
|
874
|
+
output_files = {
|
|
875
|
+
"final_karaoke_lossy_mp4": "/tmp/output.mp4",
|
|
876
|
+
"final_karaoke_lossy_720p_mp4": "/tmp/output_720p.mp4",
|
|
877
|
+
"final_karaoke_cdg_zip": "/tmp/output.zip",
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if output_files.get("final_karaoke_lossy_mp4"):
|
|
881
|
+
upload_plan.append(("MP4", "final_karaoke_lossy_mp4"))
|
|
882
|
+
if output_files.get("final_karaoke_lossy_720p_mp4"):
|
|
883
|
+
upload_plan.append(("MP4-720p", "final_karaoke_lossy_720p_mp4"))
|
|
884
|
+
if output_files.get("final_karaoke_cdg_zip"):
|
|
885
|
+
upload_plan.append(("CDG", "final_karaoke_cdg_zip"))
|
|
886
|
+
|
|
887
|
+
folders_used = [folder for folder, _ in upload_plan]
|
|
888
|
+
|
|
889
|
+
assert folders_used == expected_folders
|
|
890
|
+
|
|
891
|
+
def test_filename_format(self):
|
|
892
|
+
"""Test that uploaded files have correct naming format."""
|
|
893
|
+
brand_code = "NOMAD-1164"
|
|
894
|
+
base_name = "Artist - Title"
|
|
895
|
+
|
|
896
|
+
expected_mp4_name = f"{brand_code} - {base_name}.mp4"
|
|
897
|
+
expected_zip_name = f"{brand_code} - {base_name}.zip"
|
|
898
|
+
|
|
899
|
+
assert expected_mp4_name == "NOMAD-1164 - Artist - Title.mp4"
|
|
900
|
+
assert expected_zip_name == "NOMAD-1164 - Artist - Title.zip"
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class TestVideoWorkerDistributionLogic:
|
|
904
|
+
"""
|
|
905
|
+
Test the distribution logic in video_worker.py.
|
|
906
|
+
|
|
907
|
+
These tests verify that the video worker correctly reads job settings
|
|
908
|
+
and calls the appropriate distribution services.
|
|
909
|
+
"""
|
|
910
|
+
|
|
911
|
+
def test_dropbox_upload_requires_both_path_and_prefix(self):
|
|
912
|
+
"""
|
|
913
|
+
Test that Dropbox upload only runs when BOTH dropbox_path AND brand_prefix are set.
|
|
914
|
+
|
|
915
|
+
This mirrors the logic in video_worker.py:
|
|
916
|
+
if dropbox_path and brand_prefix:
|
|
917
|
+
# Do Dropbox upload
|
|
918
|
+
"""
|
|
919
|
+
test_cases = [
|
|
920
|
+
# (dropbox_path, brand_prefix, should_upload)
|
|
921
|
+
("/Karaoke/Tracks", "NOMAD", True),
|
|
922
|
+
("/Karaoke/Tracks", None, False),
|
|
923
|
+
(None, "NOMAD", False),
|
|
924
|
+
(None, None, False),
|
|
925
|
+
("", "NOMAD", False), # Empty string is falsy
|
|
926
|
+
("/Karaoke/Tracks", "", False),
|
|
927
|
+
]
|
|
928
|
+
|
|
929
|
+
for dropbox_path, brand_prefix, expected in test_cases:
|
|
930
|
+
should_upload = bool(dropbox_path and brand_prefix)
|
|
931
|
+
assert should_upload == expected, \
|
|
932
|
+
f"Failed for dropbox_path={dropbox_path!r}, brand_prefix={brand_prefix!r}"
|
|
933
|
+
|
|
934
|
+
def test_gdrive_upload_requires_folder_id(self):
|
|
935
|
+
"""
|
|
936
|
+
Test that Google Drive upload only runs when gdrive_folder_id is set.
|
|
937
|
+
|
|
938
|
+
This mirrors the logic in video_worker.py:
|
|
939
|
+
if gdrive_folder_id:
|
|
940
|
+
# Do Google Drive upload
|
|
941
|
+
"""
|
|
942
|
+
test_cases = [
|
|
943
|
+
# (gdrive_folder_id, should_upload)
|
|
944
|
+
("1abc123xyz", True),
|
|
945
|
+
("", False),
|
|
946
|
+
(None, False),
|
|
947
|
+
]
|
|
948
|
+
|
|
949
|
+
for gdrive_folder_id, expected in test_cases:
|
|
950
|
+
should_upload = bool(gdrive_folder_id)
|
|
951
|
+
assert should_upload == expected, \
|
|
952
|
+
f"Failed for gdrive_folder_id={gdrive_folder_id!r}"
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
class TestCompletedFeatureParity:
|
|
956
|
+
"""
|
|
957
|
+
Feature Parity Validation Tests.
|
|
958
|
+
|
|
959
|
+
These tests verify that all features marked as "completed" in the
|
|
960
|
+
BACKEND-FEATURE-PARITY-PLAN.md are actually working.
|
|
961
|
+
|
|
962
|
+
Based on the plan, completed features include:
|
|
963
|
+
- dropbox-service: Native Dropbox SDK service
|
|
964
|
+
- gdrive-service: Native Google Drive API service
|
|
965
|
+
- job-model-update: dropbox_path and gdrive_folder_id fields
|
|
966
|
+
- api-routes-update: Distribution parameters in API
|
|
967
|
+
- distribution-video-worker: Native distribution in video worker
|
|
968
|
+
- remote-cli-params: CLI parameters for distribution
|
|
969
|
+
- secrets-setup: Secret Manager credentials
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
def test_job_model_has_distribution_fields(self):
|
|
973
|
+
"""Verify Job model has all required distribution fields."""
|
|
974
|
+
from backend.models.job import Job, JobCreate
|
|
975
|
+
|
|
976
|
+
# These fields should exist on Job model
|
|
977
|
+
job_fields = Job.model_fields.keys()
|
|
978
|
+
required_fields = [
|
|
979
|
+
'brand_prefix',
|
|
980
|
+
'dropbox_path',
|
|
981
|
+
'gdrive_folder_id',
|
|
982
|
+
'discord_webhook_url',
|
|
983
|
+
'enable_youtube_upload',
|
|
984
|
+
'youtube_description',
|
|
985
|
+
'youtube_description_template',
|
|
986
|
+
]
|
|
987
|
+
|
|
988
|
+
for field in required_fields:
|
|
989
|
+
assert field in job_fields, f"Job model missing field: {field}"
|
|
990
|
+
|
|
991
|
+
# These fields should also exist on JobCreate model
|
|
992
|
+
job_create_fields = JobCreate.model_fields.keys()
|
|
993
|
+
for field in required_fields:
|
|
994
|
+
assert field in job_create_fields, f"JobCreate model missing field: {field}"
|
|
995
|
+
|
|
996
|
+
def test_dropbox_service_exists_and_has_required_methods(self):
|
|
997
|
+
"""Verify DropboxService has all required methods."""
|
|
998
|
+
from backend.services.dropbox_service import DropboxService
|
|
999
|
+
|
|
1000
|
+
required_methods = [
|
|
1001
|
+
'is_configured',
|
|
1002
|
+
'list_folders',
|
|
1003
|
+
'get_next_brand_code',
|
|
1004
|
+
'upload_file',
|
|
1005
|
+
'upload_folder',
|
|
1006
|
+
'create_shared_link',
|
|
1007
|
+
]
|
|
1008
|
+
|
|
1009
|
+
service = DropboxService()
|
|
1010
|
+
|
|
1011
|
+
for method in required_methods:
|
|
1012
|
+
assert hasattr(service, method), f"DropboxService missing method: {method}"
|
|
1013
|
+
|
|
1014
|
+
def test_gdrive_service_exists_and_has_required_methods(self):
|
|
1015
|
+
"""Verify GoogleDriveService has all required methods."""
|
|
1016
|
+
from backend.services.gdrive_service import GoogleDriveService
|
|
1017
|
+
|
|
1018
|
+
required_methods = [
|
|
1019
|
+
'is_configured',
|
|
1020
|
+
'get_or_create_folder',
|
|
1021
|
+
'upload_file',
|
|
1022
|
+
'upload_to_public_share',
|
|
1023
|
+
]
|
|
1024
|
+
|
|
1025
|
+
# Need to mock settings for initialization
|
|
1026
|
+
with patch('backend.services.gdrive_service.get_settings') as mock_settings:
|
|
1027
|
+
mock_settings.return_value = MagicMock()
|
|
1028
|
+
mock_settings.return_value.get_secret.return_value = None
|
|
1029
|
+
|
|
1030
|
+
service = GoogleDriveService()
|
|
1031
|
+
|
|
1032
|
+
for method in required_methods:
|
|
1033
|
+
assert hasattr(service, method), f"GoogleDriveService missing method: {method}"
|
|
1034
|
+
|
|
1035
|
+
def test_audio_search_request_accepts_distribution_params(self):
|
|
1036
|
+
"""Verify AudioSearchRequest model accepts distribution parameters."""
|
|
1037
|
+
from backend.api.routes.audio_search import AudioSearchRequest
|
|
1038
|
+
|
|
1039
|
+
request_fields = AudioSearchRequest.model_fields.keys()
|
|
1040
|
+
|
|
1041
|
+
distribution_fields = [
|
|
1042
|
+
'brand_prefix',
|
|
1043
|
+
'dropbox_path',
|
|
1044
|
+
'gdrive_folder_id',
|
|
1045
|
+
'discord_webhook_url',
|
|
1046
|
+
'enable_youtube_upload',
|
|
1047
|
+
'youtube_description',
|
|
1048
|
+
]
|
|
1049
|
+
|
|
1050
|
+
for field in distribution_fields:
|
|
1051
|
+
assert field in request_fields, \
|
|
1052
|
+
f"AudioSearchRequest missing distribution field: {field}"
|
|
1053
|
+
|