karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) 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 +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -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 +502 -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 +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,378 @@
1
+ """
2
+ Unit tests for audio analysis and editing services.
3
+
4
+ These tests verify the backend service wrappers that use the shared
5
+ karaoke_gen.instrumental_review module with GCS integration.
6
+
7
+ NOTE: These tests require ffmpeg to be installed. They will be skipped
8
+ if ffmpeg is not available (e.g., in CI environments without ffmpeg).
9
+ """
10
+
11
+ import os
12
+ import shutil
13
+ import tempfile
14
+ import pytest
15
+ from unittest.mock import MagicMock, patch, ANY
16
+
17
+ # Check if ffmpeg is available
18
+ def _ffmpeg_available():
19
+ """Check if ffmpeg is available on the system."""
20
+ return shutil.which('ffmpeg') is not None
21
+
22
+ # Skip all tests in this module if ffmpeg is not available
23
+ pytestmark = pytest.mark.skipif(
24
+ not _ffmpeg_available(),
25
+ reason="ffmpeg not available - required for audio processing tests"
26
+ )
27
+
28
+ from pydub import AudioSegment
29
+ from pydub.generators import Sine
30
+
31
+ from karaoke_gen.instrumental_review import MuteRegion
32
+
33
+
34
+ class TestAudioAnalysisService:
35
+ """Tests for AudioAnalysisService."""
36
+
37
+ @pytest.fixture
38
+ def mock_storage_service(self):
39
+ """Create a mock storage service."""
40
+ mock = MagicMock()
41
+ return mock
42
+
43
+ @pytest.fixture
44
+ def temp_audio_file(self, tmp_path):
45
+ """Create a temporary audio file for testing."""
46
+ audio_path = tmp_path / "test_audio.flac"
47
+
48
+ # Create a simple sine wave audio
49
+ tone = Sine(440).to_audio_segment(duration=5000) - 15
50
+ tone.export(str(audio_path), format="flac")
51
+
52
+ return str(audio_path)
53
+
54
+ @pytest.fixture
55
+ def silent_audio_file(self, tmp_path):
56
+ """Create a silent audio file for testing."""
57
+ audio_path = tmp_path / "silent_audio.flac"
58
+
59
+ audio = AudioSegment.silent(duration=5000, frame_rate=44100)
60
+ audio.export(str(audio_path), format="flac")
61
+
62
+ return str(audio_path)
63
+
64
+ def test_service_initialization(self, mock_storage_service):
65
+ """Test service initializes with default parameters."""
66
+ from backend.services.audio_analysis_service import AudioAnalysisService
67
+
68
+ service = AudioAnalysisService(storage_service=mock_storage_service)
69
+
70
+ assert service.storage_service == mock_storage_service
71
+ assert service.analyzer.silence_threshold_db == -40.0
72
+
73
+ def test_service_custom_threshold(self, mock_storage_service):
74
+ """Test service accepts custom silence threshold."""
75
+ from backend.services.audio_analysis_service import AudioAnalysisService
76
+
77
+ service = AudioAnalysisService(
78
+ storage_service=mock_storage_service,
79
+ silence_threshold_db=-35.0,
80
+ )
81
+
82
+ assert service.analyzer.silence_threshold_db == -35.0
83
+
84
+ def test_analyze_backing_vocals_downloads_file(
85
+ self, mock_storage_service, temp_audio_file
86
+ ):
87
+ """Test that analyze_backing_vocals downloads file from GCS."""
88
+ from backend.services.audio_analysis_service import AudioAnalysisService
89
+
90
+ # Set up mock to copy the temp file to the download location
91
+ def mock_download(gcs_path, local_path):
92
+ import shutil
93
+ shutil.copy(temp_audio_file, local_path)
94
+ return local_path
95
+
96
+ mock_storage_service.download_file.side_effect = mock_download
97
+
98
+ service = AudioAnalysisService(storage_service=mock_storage_service)
99
+ result = service.analyze_backing_vocals(
100
+ gcs_audio_path="jobs/test/stems/backing_vocals.flac",
101
+ job_id="test-job",
102
+ )
103
+
104
+ # Verify download was called
105
+ mock_storage_service.download_file.assert_called_once()
106
+
107
+ # Verify result is valid
108
+ assert result is not None
109
+ assert hasattr(result, 'has_audible_content')
110
+
111
+ def test_analyze_silent_audio_returns_no_audible(
112
+ self, mock_storage_service, silent_audio_file
113
+ ):
114
+ """Test that silent audio returns has_audible_content=False."""
115
+ from backend.services.audio_analysis_service import AudioAnalysisService
116
+
117
+ def mock_download(gcs_path, local_path):
118
+ import shutil
119
+ shutil.copy(silent_audio_file, local_path)
120
+ return local_path
121
+
122
+ mock_storage_service.download_file.side_effect = mock_download
123
+
124
+ service = AudioAnalysisService(storage_service=mock_storage_service)
125
+ result = service.analyze_backing_vocals(
126
+ gcs_audio_path="jobs/test/stems/backing_vocals.flac",
127
+ job_id="test-job",
128
+ )
129
+
130
+ assert result.has_audible_content is False
131
+ assert len(result.audible_segments) == 0
132
+
133
+ def test_analyze_and_generate_waveform_uploads_image(
134
+ self, mock_storage_service, temp_audio_file
135
+ ):
136
+ """Test that analyze_and_generate_waveform uploads waveform to GCS."""
137
+ from backend.services.audio_analysis_service import AudioAnalysisService
138
+
139
+ def mock_download(gcs_path, local_path):
140
+ import shutil
141
+ shutil.copy(temp_audio_file, local_path)
142
+ return local_path
143
+
144
+ mock_storage_service.download_file.side_effect = mock_download
145
+ mock_storage_service.upload_file.return_value = "jobs/test/analysis/waveform.png"
146
+
147
+ service = AudioAnalysisService(storage_service=mock_storage_service)
148
+ result, waveform_path = service.analyze_and_generate_waveform(
149
+ gcs_audio_path="jobs/test/stems/backing_vocals.flac",
150
+ job_id="test-job",
151
+ gcs_waveform_destination="jobs/test/analysis/waveform.png",
152
+ )
153
+
154
+ # Verify upload was called
155
+ mock_storage_service.upload_file.assert_called_once()
156
+
157
+ # Verify waveform path is returned
158
+ assert waveform_path == "jobs/test/analysis/waveform.png"
159
+
160
+ def test_get_waveform_data_returns_amplitudes(
161
+ self, mock_storage_service, temp_audio_file
162
+ ):
163
+ """Test that get_waveform_data returns amplitude data."""
164
+ from backend.services.audio_analysis_service import AudioAnalysisService
165
+
166
+ def mock_download(gcs_path, local_path):
167
+ import shutil
168
+ shutil.copy(temp_audio_file, local_path)
169
+ return local_path
170
+
171
+ mock_storage_service.download_file.side_effect = mock_download
172
+
173
+ service = AudioAnalysisService(storage_service=mock_storage_service)
174
+ amplitudes, duration = service.get_waveform_data(
175
+ gcs_audio_path="jobs/test/stems/backing_vocals.flac",
176
+ job_id="test-job",
177
+ num_points=100,
178
+ )
179
+
180
+ # Verify we get amplitude data
181
+ assert isinstance(amplitudes, list)
182
+ assert len(amplitudes) > 0
183
+ assert all(isinstance(a, float) for a in amplitudes)
184
+
185
+ # Verify duration is reasonable
186
+ assert duration > 0
187
+ assert duration <= 10 # Our test file is 5 seconds
188
+
189
+
190
+ class TestAudioEditingService:
191
+ """Tests for AudioEditingService."""
192
+
193
+ @pytest.fixture
194
+ def mock_storage_service(self):
195
+ """Create a mock storage service."""
196
+ mock = MagicMock()
197
+ return mock
198
+
199
+ @pytest.fixture
200
+ def temp_audio_files(self, tmp_path):
201
+ """Create temporary audio files for testing."""
202
+ clean_path = tmp_path / "clean_instrumental.flac"
203
+ backing_path = tmp_path / "backing_vocals.flac"
204
+
205
+ # Create clean instrumental (chord)
206
+ tone1 = Sine(220).to_audio_segment(duration=5000) - 15
207
+ tone2 = Sine(330).to_audio_segment(duration=5000) - 15
208
+ clean = tone1.overlay(tone2)
209
+ clean.export(str(clean_path), format="flac")
210
+
211
+ # Create backing vocals
212
+ backing = Sine(440).to_audio_segment(duration=5000) - 20
213
+ backing.export(str(backing_path), format="flac")
214
+
215
+ return str(clean_path), str(backing_path)
216
+
217
+ def test_service_initialization(self, mock_storage_service):
218
+ """Test service initializes correctly."""
219
+ from backend.services.audio_editing_service import AudioEditingService
220
+
221
+ service = AudioEditingService(storage_service=mock_storage_service)
222
+
223
+ assert service.storage_service == mock_storage_service
224
+ assert service.editor.output_format == "flac"
225
+
226
+ def test_create_custom_instrumental_downloads_files(
227
+ self, mock_storage_service, temp_audio_files
228
+ ):
229
+ """Test that create_custom_instrumental downloads input files."""
230
+ from backend.services.audio_editing_service import AudioEditingService
231
+
232
+ clean_path, backing_path = temp_audio_files
233
+
234
+ # Track which files were downloaded
235
+ downloaded_files = []
236
+
237
+ def mock_download(gcs_path, local_path):
238
+ downloaded_files.append(gcs_path)
239
+ # Copy the appropriate test file
240
+ import shutil
241
+ if 'clean' in gcs_path:
242
+ shutil.copy(clean_path, local_path)
243
+ else:
244
+ shutil.copy(backing_path, local_path)
245
+ return local_path
246
+
247
+ mock_storage_service.download_file.side_effect = mock_download
248
+ mock_storage_service.upload_file.return_value = "jobs/test/stems/custom.flac"
249
+
250
+ service = AudioEditingService(storage_service=mock_storage_service)
251
+
252
+ mute_regions = [MuteRegion(start_seconds=1.0, end_seconds=2.0)]
253
+
254
+ result = service.create_custom_instrumental(
255
+ gcs_clean_instrumental_path="jobs/test/stems/clean.flac",
256
+ gcs_backing_vocals_path="jobs/test/stems/backing.flac",
257
+ mute_regions=mute_regions,
258
+ gcs_output_path="jobs/test/stems/custom.flac",
259
+ job_id="test-job",
260
+ )
261
+
262
+ # Verify both files were downloaded
263
+ assert len(downloaded_files) == 2
264
+ assert mock_storage_service.upload_file.called
265
+
266
+ def test_create_custom_instrumental_uploads_result(
267
+ self, mock_storage_service, temp_audio_files
268
+ ):
269
+ """Test that create_custom_instrumental uploads the result."""
270
+ from backend.services.audio_editing_service import AudioEditingService
271
+
272
+ clean_path, backing_path = temp_audio_files
273
+
274
+ def mock_download(gcs_path, local_path):
275
+ import shutil
276
+ if 'clean' in gcs_path:
277
+ shutil.copy(clean_path, local_path)
278
+ else:
279
+ shutil.copy(backing_path, local_path)
280
+ return local_path
281
+
282
+ mock_storage_service.download_file.side_effect = mock_download
283
+ mock_storage_service.upload_file.return_value = "jobs/test/stems/custom.flac"
284
+
285
+ service = AudioEditingService(storage_service=mock_storage_service)
286
+
287
+ mute_regions = [MuteRegion(start_seconds=1.0, end_seconds=2.0)]
288
+
289
+ result = service.create_custom_instrumental(
290
+ gcs_clean_instrumental_path="jobs/test/stems/clean.flac",
291
+ gcs_backing_vocals_path="jobs/test/stems/backing.flac",
292
+ mute_regions=mute_regions,
293
+ gcs_output_path="jobs/test/stems/custom.flac",
294
+ job_id="test-job",
295
+ )
296
+
297
+ # Verify upload was called with correct destination
298
+ mock_storage_service.upload_file.assert_called_once()
299
+ call_args = mock_storage_service.upload_file.call_args
300
+ assert call_args[0][1] == "jobs/test/stems/custom.flac"
301
+
302
+ # Verify result has correct path
303
+ assert result.output_path == "jobs/test/stems/custom.flac"
304
+
305
+ def test_create_custom_instrumental_returns_statistics(
306
+ self, mock_storage_service, temp_audio_files
307
+ ):
308
+ """Test that create_custom_instrumental returns correct statistics."""
309
+ from backend.services.audio_editing_service import AudioEditingService
310
+
311
+ clean_path, backing_path = temp_audio_files
312
+
313
+ def mock_download(gcs_path, local_path):
314
+ import shutil
315
+ if 'clean' in gcs_path:
316
+ shutil.copy(clean_path, local_path)
317
+ else:
318
+ shutil.copy(backing_path, local_path)
319
+ return local_path
320
+
321
+ mock_storage_service.download_file.side_effect = mock_download
322
+ mock_storage_service.upload_file.return_value = "jobs/test/stems/custom.flac"
323
+
324
+ service = AudioEditingService(storage_service=mock_storage_service)
325
+
326
+ mute_regions = [
327
+ MuteRegion(start_seconds=1.0, end_seconds=2.0),
328
+ MuteRegion(start_seconds=3.0, end_seconds=4.0),
329
+ ]
330
+
331
+ result = service.create_custom_instrumental(
332
+ gcs_clean_instrumental_path="jobs/test/stems/clean.flac",
333
+ gcs_backing_vocals_path="jobs/test/stems/backing.flac",
334
+ mute_regions=mute_regions,
335
+ gcs_output_path="jobs/test/stems/custom.flac",
336
+ job_id="test-job",
337
+ )
338
+
339
+ # Verify statistics
340
+ assert len(result.mute_regions_applied) == 2
341
+ assert result.total_muted_duration_seconds == 2.0 # 1s + 1s
342
+ assert result.output_duration_seconds > 0
343
+
344
+ def test_validate_mute_regions_returns_errors_for_invalid(
345
+ self, mock_storage_service
346
+ ):
347
+ """Test that validate_mute_regions catches invalid regions."""
348
+ from backend.services.audio_editing_service import AudioEditingService
349
+
350
+ service = AudioEditingService(storage_service=mock_storage_service)
351
+
352
+ mute_regions = [
353
+ MuteRegion(start_seconds=0.0, end_seconds=1.0), # Valid
354
+ MuteRegion(start_seconds=100.0, end_seconds=110.0), # Beyond duration
355
+ ]
356
+
357
+ errors = service.validate_mute_regions(mute_regions, total_duration_seconds=60.0)
358
+
359
+ # Should have error for region exceeding duration
360
+ assert len(errors) == 1
361
+ assert "exceeds" in errors[0].lower()
362
+
363
+ def test_validate_mute_regions_returns_empty_for_valid(
364
+ self, mock_storage_service
365
+ ):
366
+ """Test that validate_mute_regions returns empty for valid regions."""
367
+ from backend.services.audio_editing_service import AudioEditingService
368
+
369
+ service = AudioEditingService(storage_service=mock_storage_service)
370
+
371
+ mute_regions = [
372
+ MuteRegion(start_seconds=0.0, end_seconds=1.0),
373
+ MuteRegion(start_seconds=10.0, end_seconds=15.0),
374
+ ]
375
+
376
+ errors = service.validate_mute_regions(mute_regions, total_duration_seconds=60.0)
377
+
378
+ assert len(errors) == 0
@@ -0,0 +1,231 @@
1
+ """
2
+ Tests for AuthService and FirestoreService.
3
+
4
+ These tests focus on the service initialization and basic operations.
5
+ """
6
+ import pytest
7
+ from datetime import datetime, UTC
8
+ from unittest.mock import MagicMock, patch
9
+
10
+
11
+ class TestAuthServiceBasics:
12
+ """Basic tests for AuthService."""
13
+
14
+ def test_auth_service_can_import(self):
15
+ """Test AuthService can be imported."""
16
+ from backend.services.auth_service import AuthService
17
+ assert AuthService is not None
18
+
19
+ def test_auth_service_initialization(self):
20
+ """Test AuthService initializes with mocked dependencies."""
21
+ mock_firestore = MagicMock()
22
+
23
+ with patch('backend.services.auth_service.FirestoreService', return_value=mock_firestore), \
24
+ patch.dict('os.environ', {'ADMIN_TOKENS': 'test-token'}):
25
+ from backend.services.auth_service import AuthService
26
+ service = AuthService()
27
+ assert service is not None
28
+
29
+ def test_auth_service_has_validate_method(self):
30
+ """Test AuthService has token validation method."""
31
+ from backend.services.auth_service import AuthService
32
+ assert hasattr(AuthService, 'validate_token') or hasattr(AuthService, 'verify_token')
33
+
34
+
35
+ class TestFirestoreServiceBasics:
36
+ """Basic tests for FirestoreService."""
37
+
38
+ def test_firestore_service_can_import(self):
39
+ """Test FirestoreService can be imported."""
40
+ from backend.services.firestore_service import FirestoreService
41
+ assert FirestoreService is not None
42
+
43
+ def test_firestore_service_initialization(self):
44
+ """Test FirestoreService initializes with mocked client."""
45
+ mock_client = MagicMock()
46
+
47
+ with patch('backend.services.firestore_service.firestore.Client', return_value=mock_client):
48
+ from backend.services.firestore_service import FirestoreService
49
+ service = FirestoreService()
50
+ assert service is not None
51
+
52
+ def test_firestore_service_has_crud_methods(self):
53
+ """Test FirestoreService has CRUD methods."""
54
+ from backend.services.firestore_service import FirestoreService
55
+ assert hasattr(FirestoreService, 'create_job')
56
+ assert hasattr(FirestoreService, 'get_job')
57
+ assert hasattr(FirestoreService, 'update_job')
58
+ assert hasattr(FirestoreService, 'delete_job')
59
+
60
+
61
+ class TestStorageServiceBasics:
62
+ """Basic tests for StorageService."""
63
+
64
+ def test_storage_service_can_import(self):
65
+ """Test StorageService can be imported."""
66
+ from backend.services.storage_service import StorageService
67
+ assert StorageService is not None
68
+
69
+ def test_storage_service_initialization(self):
70
+ """Test StorageService initializes with mocked client."""
71
+ mock_client = MagicMock()
72
+ mock_bucket = MagicMock()
73
+ mock_client.bucket.return_value = mock_bucket
74
+
75
+ with patch('backend.services.storage_service.storage.Client', return_value=mock_client):
76
+ from backend.services.storage_service import StorageService
77
+ service = StorageService()
78
+ assert service is not None
79
+
80
+ def test_storage_service_has_file_methods(self):
81
+ """Test StorageService has file operation methods."""
82
+ from backend.services.storage_service import StorageService
83
+ assert hasattr(StorageService, 'upload_file')
84
+ assert hasattr(StorageService, 'download_file')
85
+
86
+
87
+ class TestWorkerServiceBasics:
88
+ """Basic tests for WorkerService."""
89
+
90
+ def test_worker_service_can_import(self):
91
+ """Test WorkerService can be imported."""
92
+ from backend.services.worker_service import WorkerService
93
+ assert WorkerService is not None
94
+
95
+ def test_worker_service_initialization(self):
96
+ """Test WorkerService initializes correctly."""
97
+ from backend.services.worker_service import WorkerService
98
+ service = WorkerService()
99
+ assert service is not None
100
+
101
+ def test_worker_service_has_trigger_methods(self):
102
+ """Test WorkerService has trigger methods."""
103
+ from backend.services.worker_service import WorkerService
104
+ assert hasattr(WorkerService, 'trigger_audio_worker')
105
+ assert hasattr(WorkerService, 'trigger_lyrics_worker')
106
+ assert hasattr(WorkerService, 'trigger_screens_worker')
107
+ assert hasattr(WorkerService, 'trigger_video_worker')
108
+
109
+ def test_worker_service_get_base_url(self):
110
+ """Test WorkerService constructs base URL."""
111
+ from backend.services.worker_service import WorkerService
112
+ service = WorkerService()
113
+ url = service._get_base_url()
114
+ assert 'http' in url
115
+
116
+
117
+ class TestSessionTokenValidation:
118
+ """Tests for session token validation in AuthService.
119
+
120
+ PR #120: Fixed authentication to recognize session tokens from magic link auth.
121
+ Previously, validate_token() only checked admin tokens and auth_tokens collection.
122
+ Session tokens (created via magic link) are stored in the sessions collection,
123
+ so they were incorrectly rejected as "Invalid token".
124
+ """
125
+
126
+ def test_validate_token_checks_session_on_fallback(self):
127
+ """Test that validate_token falls back to checking session tokens."""
128
+ mock_firestore = MagicMock()
129
+ mock_firestore.get_token.return_value = None # Token not in auth_tokens
130
+
131
+ mock_user = MagicMock()
132
+ mock_user.email = "test@example.com"
133
+ mock_user.credits = 5
134
+
135
+ mock_user_service = MagicMock()
136
+ mock_user_service.validate_session.return_value = (True, mock_user, "Valid session")
137
+
138
+ with patch('backend.services.auth_service.FirestoreService', return_value=mock_firestore), \
139
+ patch('backend.services.user_service.get_user_service', return_value=mock_user_service), \
140
+ patch.dict('os.environ', {'ADMIN_TOKENS': 'admin-token-123'}):
141
+ # Clear the cached instance to get fresh state
142
+ import backend.services.auth_service
143
+ backend.services.auth_service._auth_service = None
144
+ from backend.services.auth_service import AuthService, UserType
145
+
146
+ service = AuthService()
147
+ is_valid, user_type, remaining, msg = service.validate_token("session-token-xyz")
148
+
149
+ assert is_valid is True
150
+ assert user_type == UserType.STRIPE
151
+ assert remaining == 5 # User's credits
152
+ mock_user_service.validate_session.assert_called_once_with("session-token-xyz")
153
+
154
+ def test_validate_token_returns_invalid_when_session_invalid(self):
155
+ """Test that validate_token returns invalid when session validation fails."""
156
+ mock_firestore = MagicMock()
157
+ mock_firestore.get_token.return_value = None # Token not in auth_tokens
158
+
159
+ mock_user_service = MagicMock()
160
+ mock_user_service.validate_session.return_value = (False, None, "Invalid session")
161
+
162
+ with patch('backend.services.auth_service.FirestoreService', return_value=mock_firestore), \
163
+ patch('backend.services.user_service.get_user_service', return_value=mock_user_service), \
164
+ patch.dict('os.environ', {'ADMIN_TOKENS': 'admin-token-123'}):
165
+ import backend.services.auth_service
166
+ backend.services.auth_service._auth_service = None
167
+ from backend.services.auth_service import AuthService, UserType
168
+
169
+ service = AuthService()
170
+ is_valid, user_type, remaining, msg = service.validate_token("invalid-session")
171
+
172
+ assert is_valid is False
173
+ assert remaining == 0
174
+ assert "Invalid token" in msg
175
+
176
+ def test_validate_token_returns_zero_credits_when_user_has_none(self):
177
+ """Test that validate_token handles users with 0 credits correctly."""
178
+ mock_firestore = MagicMock()
179
+ mock_firestore.get_token.return_value = None
180
+
181
+ mock_user = MagicMock()
182
+ mock_user.email = "test@example.com"
183
+ mock_user.credits = 0
184
+
185
+ mock_user_service = MagicMock()
186
+ mock_user_service.validate_session.return_value = (True, mock_user, "Valid session")
187
+
188
+ with patch('backend.services.auth_service.FirestoreService', return_value=mock_firestore), \
189
+ patch('backend.services.user_service.get_user_service', return_value=mock_user_service), \
190
+ patch.dict('os.environ', {'ADMIN_TOKENS': 'admin-token-123'}):
191
+ import backend.services.auth_service
192
+ backend.services.auth_service._auth_service = None
193
+ from backend.services.auth_service import AuthService, UserType
194
+
195
+ service = AuthService()
196
+ is_valid, user_type, remaining, msg = service.validate_token("session-token")
197
+
198
+ # User is authenticated but has no credits
199
+ assert is_valid is True
200
+ assert user_type == UserType.STRIPE
201
+ assert remaining == 0
202
+ assert "no credits" in msg.lower()
203
+
204
+
205
+ class TestJobManagerBasics:
206
+ """Basic tests for JobManager."""
207
+
208
+ def test_job_manager_can_import(self):
209
+ """Test JobManager can be imported."""
210
+ from backend.services.job_manager import JobManager
211
+ assert JobManager is not None
212
+
213
+ def test_job_manager_initialization(self):
214
+ """Test JobManager initializes with mocked services."""
215
+ mock_firestore = MagicMock()
216
+ mock_storage = MagicMock()
217
+
218
+ with patch('backend.services.job_manager.FirestoreService', return_value=mock_firestore), \
219
+ patch('backend.services.job_manager.StorageService', return_value=mock_storage):
220
+ from backend.services.job_manager import JobManager
221
+ manager = JobManager()
222
+ assert manager is not None
223
+
224
+ def test_job_manager_has_crud_methods(self):
225
+ """Test JobManager has CRUD methods."""
226
+ from backend.services.job_manager import JobManager
227
+ assert hasattr(JobManager, 'create_job')
228
+ assert hasattr(JobManager, 'get_job')
229
+ assert hasattr(JobManager, 'delete_job')
230
+ assert hasattr(JobManager, 'list_jobs')
231
+
@@ -0,0 +1,68 @@
1
+ """
2
+ Extended tests for config.py to improve coverage.
3
+ """
4
+ import pytest
5
+ import os
6
+ from unittest.mock import patch
7
+
8
+
9
+ class TestSettingsConfiguration:
10
+ """Tests for Settings configuration."""
11
+
12
+ def test_settings_class_exists(self):
13
+ """Test Settings class exists."""
14
+ from backend.config import Settings
15
+ assert Settings is not None
16
+
17
+ def test_settings_loads_defaults(self):
18
+ """Test Settings loads default values."""
19
+ from backend.config import Settings
20
+ settings = Settings()
21
+ assert settings is not None
22
+
23
+ def test_gcs_bucket_name_from_env(self):
24
+ """Test GCS bucket name from environment."""
25
+ with patch.dict(os.environ, {'GCS_BUCKET_NAME': 'test-bucket'}):
26
+ from backend.config import Settings
27
+ settings = Settings()
28
+ assert settings.gcs_bucket_name == 'test-bucket'
29
+
30
+ def test_firestore_collection_from_env(self):
31
+ """Test Firestore collection from environment."""
32
+ with patch.dict(os.environ, {'FIRESTORE_COLLECTION': 'test-jobs'}):
33
+ from backend.config import Settings
34
+ settings = Settings()
35
+ assert settings.firestore_collection == 'test-jobs'
36
+
37
+ def test_environment_setting(self):
38
+ """Test environment setting."""
39
+ from backend.config import Settings
40
+ settings = Settings()
41
+ # Accept 'test' as well since pytest may set ENVIRONMENT=test
42
+ assert settings.environment in ['development', 'production', 'testing', 'test']
43
+
44
+ def test_google_cloud_project(self):
45
+ """Test Google Cloud project setting."""
46
+ with patch.dict(os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'}):
47
+ from backend.config import Settings
48
+ settings = Settings()
49
+ # Should either use the env var or have a default
50
+
51
+
52
+ class TestGetSettings:
53
+ """Tests for get_settings function."""
54
+
55
+ def test_get_settings_returns_settings(self):
56
+ """Test get_settings returns Settings instance."""
57
+ from backend.config import get_settings
58
+ settings = get_settings()
59
+ assert settings is not None
60
+
61
+ def test_get_settings_returns_same_instance(self):
62
+ """Test get_settings returns cached instance."""
63
+ from backend.config import get_settings
64
+ settings1 = get_settings()
65
+ settings2 = get_settings()
66
+ # Should be same cached instance
67
+ assert settings1 is settings2
68
+