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.
Files changed (187) 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/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {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
+