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.
- 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 +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -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 +502 -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 +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -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/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -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 +109 -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 +356 -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 +283 -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_spacy_preloader.py +119 -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/utils/test_data.py +27 -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 +535 -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.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- 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.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {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
|
+
|