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,119 @@
|
|
|
1
|
+
"""Tests for SpaCy preloader service."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
|
|
6
|
+
from backend.services.spacy_preloader import (
|
|
7
|
+
preload_spacy_model,
|
|
8
|
+
get_preloaded_model,
|
|
9
|
+
is_model_preloaded,
|
|
10
|
+
clear_preloaded_models,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestSpacyPreloader:
|
|
15
|
+
"""Tests for SpaCy preloading functionality."""
|
|
16
|
+
|
|
17
|
+
def setup_method(self):
|
|
18
|
+
"""Clear preloaded models before each test."""
|
|
19
|
+
clear_preloaded_models()
|
|
20
|
+
|
|
21
|
+
def teardown_method(self):
|
|
22
|
+
"""Clear preloaded models after each test."""
|
|
23
|
+
clear_preloaded_models()
|
|
24
|
+
|
|
25
|
+
def test_preload_spacy_model_loads_and_stores(self):
|
|
26
|
+
"""GIVEN no preloaded models
|
|
27
|
+
WHEN preload_spacy_model is called
|
|
28
|
+
THEN model should be loaded and stored in singleton."""
|
|
29
|
+
mock_nlp = MagicMock()
|
|
30
|
+
|
|
31
|
+
with patch("spacy.load", return_value=mock_nlp) as mock_load:
|
|
32
|
+
preload_spacy_model("en_core_web_sm")
|
|
33
|
+
|
|
34
|
+
mock_load.assert_called_once_with("en_core_web_sm")
|
|
35
|
+
assert is_model_preloaded("en_core_web_sm")
|
|
36
|
+
assert get_preloaded_model("en_core_web_sm") is mock_nlp
|
|
37
|
+
|
|
38
|
+
def test_preload_is_idempotent(self):
|
|
39
|
+
"""GIVEN a model already preloaded
|
|
40
|
+
WHEN preload_spacy_model is called again
|
|
41
|
+
THEN model should not be reloaded."""
|
|
42
|
+
mock_nlp = MagicMock()
|
|
43
|
+
|
|
44
|
+
with patch("spacy.load", return_value=mock_nlp) as mock_load:
|
|
45
|
+
preload_spacy_model("en_core_web_sm")
|
|
46
|
+
preload_spacy_model("en_core_web_sm") # Second call
|
|
47
|
+
|
|
48
|
+
# Should only load once
|
|
49
|
+
assert mock_load.call_count == 1
|
|
50
|
+
|
|
51
|
+
def test_get_preloaded_model_returns_none_if_not_loaded(self):
|
|
52
|
+
"""GIVEN no preloaded models
|
|
53
|
+
WHEN get_preloaded_model is called
|
|
54
|
+
THEN should return None."""
|
|
55
|
+
assert get_preloaded_model("en_core_web_sm") is None
|
|
56
|
+
assert not is_model_preloaded("en_core_web_sm")
|
|
57
|
+
|
|
58
|
+
def test_preload_different_models(self):
|
|
59
|
+
"""GIVEN no preloaded models
|
|
60
|
+
WHEN preload_spacy_model is called with different model names
|
|
61
|
+
THEN each model should be loaded and stored separately."""
|
|
62
|
+
mock_nlp_sm = MagicMock(name="en_core_web_sm")
|
|
63
|
+
mock_nlp_md = MagicMock(name="en_core_web_md")
|
|
64
|
+
|
|
65
|
+
def mock_load(model_name):
|
|
66
|
+
if model_name == "en_core_web_sm":
|
|
67
|
+
return mock_nlp_sm
|
|
68
|
+
elif model_name == "en_core_web_md":
|
|
69
|
+
return mock_nlp_md
|
|
70
|
+
raise ValueError(f"Unknown model: {model_name}")
|
|
71
|
+
|
|
72
|
+
with patch("spacy.load", side_effect=mock_load):
|
|
73
|
+
preload_spacy_model("en_core_web_sm")
|
|
74
|
+
preload_spacy_model("en_core_web_md")
|
|
75
|
+
|
|
76
|
+
assert is_model_preloaded("en_core_web_sm")
|
|
77
|
+
assert is_model_preloaded("en_core_web_md")
|
|
78
|
+
assert get_preloaded_model("en_core_web_sm") is mock_nlp_sm
|
|
79
|
+
assert get_preloaded_model("en_core_web_md") is mock_nlp_md
|
|
80
|
+
|
|
81
|
+
def test_clear_preloaded_models(self):
|
|
82
|
+
"""GIVEN preloaded models
|
|
83
|
+
WHEN clear_preloaded_models is called
|
|
84
|
+
THEN all models should be removed."""
|
|
85
|
+
mock_nlp = MagicMock()
|
|
86
|
+
|
|
87
|
+
with patch("spacy.load", return_value=mock_nlp):
|
|
88
|
+
preload_spacy_model("en_core_web_sm")
|
|
89
|
+
assert is_model_preloaded("en_core_web_sm")
|
|
90
|
+
|
|
91
|
+
clear_preloaded_models()
|
|
92
|
+
assert not is_model_preloaded("en_core_web_sm")
|
|
93
|
+
assert get_preloaded_model("en_core_web_sm") is None
|
|
94
|
+
|
|
95
|
+
def test_preload_failure_raises_exception(self):
|
|
96
|
+
"""GIVEN spacy.load raises an exception
|
|
97
|
+
WHEN preload_spacy_model is called
|
|
98
|
+
THEN exception should be propagated."""
|
|
99
|
+
with patch(
|
|
100
|
+
"spacy.load",
|
|
101
|
+
side_effect=OSError("Model not found"),
|
|
102
|
+
):
|
|
103
|
+
with pytest.raises(OSError, match="Model not found"):
|
|
104
|
+
preload_spacy_model("nonexistent_model")
|
|
105
|
+
|
|
106
|
+
# Model should not be marked as preloaded
|
|
107
|
+
assert not is_model_preloaded("nonexistent_model")
|
|
108
|
+
|
|
109
|
+
def test_preload_uses_default_model_name(self):
|
|
110
|
+
"""GIVEN no model name specified
|
|
111
|
+
WHEN preload_spacy_model is called without arguments
|
|
112
|
+
THEN should use en_core_web_sm as default."""
|
|
113
|
+
mock_nlp = MagicMock()
|
|
114
|
+
|
|
115
|
+
with patch("spacy.load", return_value=mock_nlp) as mock_load:
|
|
116
|
+
preload_spacy_model()
|
|
117
|
+
|
|
118
|
+
mock_load.assert_called_once_with("en_core_web_sm")
|
|
119
|
+
assert is_model_preloaded("en_core_web_sm")
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for storage_service.py - Google Cloud Storage operations.
|
|
3
|
+
|
|
4
|
+
These tests mock the GCS client to verify:
|
|
5
|
+
- Upload operations (file, fileobj, JSON)
|
|
6
|
+
- Download operations (file, JSON)
|
|
7
|
+
- Signed URL generation
|
|
8
|
+
- File operations (delete, list, exists)
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import pytest
|
|
12
|
+
from unittest.mock import Mock, MagicMock, patch
|
|
13
|
+
from io import BytesIO
|
|
14
|
+
|
|
15
|
+
from backend.services.storage_service import StorageService
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestStorageServiceInit:
|
|
19
|
+
"""Test StorageService initialization."""
|
|
20
|
+
|
|
21
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
22
|
+
@patch("backend.services.storage_service.settings")
|
|
23
|
+
def test_init_creates_client_and_bucket(self, mock_settings, mock_client_class):
|
|
24
|
+
"""Test initialization creates GCS client and gets bucket."""
|
|
25
|
+
mock_settings.google_cloud_project = "test-project"
|
|
26
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
27
|
+
|
|
28
|
+
mock_client = Mock()
|
|
29
|
+
mock_bucket = Mock()
|
|
30
|
+
mock_client.bucket.return_value = mock_bucket
|
|
31
|
+
mock_client_class.return_value = mock_client
|
|
32
|
+
|
|
33
|
+
service = StorageService()
|
|
34
|
+
|
|
35
|
+
mock_client_class.assert_called_once_with(project="test-project")
|
|
36
|
+
mock_client.bucket.assert_called_once_with("test-bucket")
|
|
37
|
+
assert service.client == mock_client
|
|
38
|
+
assert service.bucket == mock_bucket
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestStorageServiceUpload:
|
|
42
|
+
"""Test upload operations."""
|
|
43
|
+
|
|
44
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
45
|
+
@patch("backend.services.storage_service.settings")
|
|
46
|
+
def test_upload_file(self, mock_settings, mock_client_class):
|
|
47
|
+
"""Test uploading a file to GCS."""
|
|
48
|
+
mock_settings.google_cloud_project = "test-project"
|
|
49
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
50
|
+
|
|
51
|
+
mock_blob = Mock()
|
|
52
|
+
mock_bucket = Mock()
|
|
53
|
+
mock_bucket.blob.return_value = mock_blob
|
|
54
|
+
mock_client = Mock()
|
|
55
|
+
mock_client.bucket.return_value = mock_bucket
|
|
56
|
+
mock_client_class.return_value = mock_client
|
|
57
|
+
|
|
58
|
+
service = StorageService()
|
|
59
|
+
result = service.upload_file("/local/path/file.flac", "uploads/job123/file.flac")
|
|
60
|
+
|
|
61
|
+
mock_bucket.blob.assert_called_once_with("uploads/job123/file.flac")
|
|
62
|
+
mock_blob.upload_from_filename.assert_called_once_with("/local/path/file.flac")
|
|
63
|
+
assert result == "uploads/job123/file.flac"
|
|
64
|
+
|
|
65
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
66
|
+
@patch("backend.services.storage_service.settings")
|
|
67
|
+
def test_upload_file_raises_on_error(self, mock_settings, mock_client_class):
|
|
68
|
+
"""Test upload_file raises exception on GCS error."""
|
|
69
|
+
mock_settings.google_cloud_project = "test-project"
|
|
70
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
71
|
+
|
|
72
|
+
mock_blob = Mock()
|
|
73
|
+
mock_blob.upload_from_filename.side_effect = Exception("GCS error")
|
|
74
|
+
mock_bucket = Mock()
|
|
75
|
+
mock_bucket.blob.return_value = mock_blob
|
|
76
|
+
mock_client = Mock()
|
|
77
|
+
mock_client.bucket.return_value = mock_bucket
|
|
78
|
+
mock_client_class.return_value = mock_client
|
|
79
|
+
|
|
80
|
+
service = StorageService()
|
|
81
|
+
|
|
82
|
+
with pytest.raises(Exception) as exc_info:
|
|
83
|
+
service.upload_file("/local/path/file.flac", "dest/file.flac")
|
|
84
|
+
|
|
85
|
+
assert "GCS error" in str(exc_info.value)
|
|
86
|
+
|
|
87
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
88
|
+
@patch("backend.services.storage_service.settings")
|
|
89
|
+
def test_upload_fileobj(self, mock_settings, mock_client_class):
|
|
90
|
+
"""Test uploading a file object to GCS."""
|
|
91
|
+
mock_settings.google_cloud_project = "test-project"
|
|
92
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
93
|
+
|
|
94
|
+
mock_blob = Mock()
|
|
95
|
+
mock_bucket = Mock()
|
|
96
|
+
mock_bucket.blob.return_value = mock_blob
|
|
97
|
+
mock_client = Mock()
|
|
98
|
+
mock_client.bucket.return_value = mock_bucket
|
|
99
|
+
mock_client_class.return_value = mock_client
|
|
100
|
+
|
|
101
|
+
service = StorageService()
|
|
102
|
+
file_obj = BytesIO(b"test content")
|
|
103
|
+
|
|
104
|
+
result = service.upload_fileobj(file_obj, "uploads/test.txt", content_type="text/plain")
|
|
105
|
+
|
|
106
|
+
mock_bucket.blob.assert_called_once_with("uploads/test.txt")
|
|
107
|
+
assert mock_blob.content_type == "text/plain"
|
|
108
|
+
mock_blob.upload_from_file.assert_called_once_with(file_obj, rewind=True)
|
|
109
|
+
assert result == "uploads/test.txt"
|
|
110
|
+
|
|
111
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
112
|
+
@patch("backend.services.storage_service.settings")
|
|
113
|
+
def test_upload_json(self, mock_settings, mock_client_class):
|
|
114
|
+
"""Test uploading JSON data to GCS."""
|
|
115
|
+
mock_settings.google_cloud_project = "test-project"
|
|
116
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
117
|
+
|
|
118
|
+
mock_blob = Mock()
|
|
119
|
+
mock_bucket = Mock()
|
|
120
|
+
mock_bucket.blob.return_value = mock_blob
|
|
121
|
+
mock_client = Mock()
|
|
122
|
+
mock_client.bucket.return_value = mock_bucket
|
|
123
|
+
mock_client_class.return_value = mock_client
|
|
124
|
+
|
|
125
|
+
service = StorageService()
|
|
126
|
+
data = {"key": "value", "nested": {"data": True}}
|
|
127
|
+
|
|
128
|
+
result = service.upload_json("data/config.json", data)
|
|
129
|
+
|
|
130
|
+
mock_bucket.blob.assert_called_once_with("data/config.json")
|
|
131
|
+
assert mock_blob.content_type == "application/json"
|
|
132
|
+
# Check the uploaded content
|
|
133
|
+
call_args = mock_blob.upload_from_string.call_args
|
|
134
|
+
uploaded_content = call_args[0][0]
|
|
135
|
+
assert json.loads(uploaded_content) == data
|
|
136
|
+
assert result == "data/config.json"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestStorageServiceDownload:
|
|
140
|
+
"""Test download operations."""
|
|
141
|
+
|
|
142
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
143
|
+
@patch("backend.services.storage_service.settings")
|
|
144
|
+
def test_download_file(self, mock_settings, mock_client_class):
|
|
145
|
+
"""Test downloading a file from GCS."""
|
|
146
|
+
mock_settings.google_cloud_project = "test-project"
|
|
147
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
148
|
+
|
|
149
|
+
mock_blob = Mock()
|
|
150
|
+
mock_bucket = Mock()
|
|
151
|
+
mock_bucket.blob.return_value = mock_blob
|
|
152
|
+
mock_client = Mock()
|
|
153
|
+
mock_client.bucket.return_value = mock_bucket
|
|
154
|
+
mock_client_class.return_value = mock_client
|
|
155
|
+
|
|
156
|
+
service = StorageService()
|
|
157
|
+
result = service.download_file("uploads/job123/file.flac", "/local/path/file.flac")
|
|
158
|
+
|
|
159
|
+
mock_bucket.blob.assert_called_once_with("uploads/job123/file.flac")
|
|
160
|
+
mock_blob.download_to_filename.assert_called_once_with("/local/path/file.flac")
|
|
161
|
+
assert result == "/local/path/file.flac"
|
|
162
|
+
|
|
163
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
164
|
+
@patch("backend.services.storage_service.settings")
|
|
165
|
+
def test_download_file_raises_on_error(self, mock_settings, mock_client_class):
|
|
166
|
+
"""Test download_file raises exception on GCS error."""
|
|
167
|
+
mock_settings.google_cloud_project = "test-project"
|
|
168
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
169
|
+
|
|
170
|
+
mock_blob = Mock()
|
|
171
|
+
mock_blob.download_to_filename.side_effect = Exception("File not found")
|
|
172
|
+
mock_bucket = Mock()
|
|
173
|
+
mock_bucket.blob.return_value = mock_blob
|
|
174
|
+
mock_client = Mock()
|
|
175
|
+
mock_client.bucket.return_value = mock_bucket
|
|
176
|
+
mock_client_class.return_value = mock_client
|
|
177
|
+
|
|
178
|
+
service = StorageService()
|
|
179
|
+
|
|
180
|
+
with pytest.raises(Exception) as exc_info:
|
|
181
|
+
service.download_file("missing/file.flac", "/local/path")
|
|
182
|
+
|
|
183
|
+
assert "File not found" in str(exc_info.value)
|
|
184
|
+
|
|
185
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
186
|
+
@patch("backend.services.storage_service.settings")
|
|
187
|
+
def test_download_json(self, mock_settings, mock_client_class):
|
|
188
|
+
"""Test downloading and parsing JSON from GCS."""
|
|
189
|
+
mock_settings.google_cloud_project = "test-project"
|
|
190
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
191
|
+
|
|
192
|
+
mock_blob = Mock()
|
|
193
|
+
mock_blob.download_as_text.return_value = '{"key": "value", "count": 42}'
|
|
194
|
+
mock_bucket = Mock()
|
|
195
|
+
mock_bucket.blob.return_value = mock_blob
|
|
196
|
+
mock_client = Mock()
|
|
197
|
+
mock_client.bucket.return_value = mock_bucket
|
|
198
|
+
mock_client_class.return_value = mock_client
|
|
199
|
+
|
|
200
|
+
service = StorageService()
|
|
201
|
+
result = service.download_json("data/config.json")
|
|
202
|
+
|
|
203
|
+
assert result == {"key": "value", "count": 42}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestStorageServiceDelete:
|
|
207
|
+
"""Test delete operations."""
|
|
208
|
+
|
|
209
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
210
|
+
@patch("backend.services.storage_service.settings")
|
|
211
|
+
def test_delete_file(self, mock_settings, mock_client_class):
|
|
212
|
+
"""Test deleting a single file from GCS."""
|
|
213
|
+
mock_settings.google_cloud_project = "test-project"
|
|
214
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
215
|
+
|
|
216
|
+
mock_blob = Mock()
|
|
217
|
+
mock_bucket = Mock()
|
|
218
|
+
mock_bucket.blob.return_value = mock_blob
|
|
219
|
+
mock_client = Mock()
|
|
220
|
+
mock_client.bucket.return_value = mock_bucket
|
|
221
|
+
mock_client_class.return_value = mock_client
|
|
222
|
+
|
|
223
|
+
service = StorageService()
|
|
224
|
+
service.delete_file("uploads/job123/file.flac")
|
|
225
|
+
|
|
226
|
+
mock_bucket.blob.assert_called_once_with("uploads/job123/file.flac")
|
|
227
|
+
mock_blob.delete.assert_called_once()
|
|
228
|
+
|
|
229
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
230
|
+
@patch("backend.services.storage_service.settings")
|
|
231
|
+
def test_delete_folder(self, mock_settings, mock_client_class):
|
|
232
|
+
"""Test deleting all files with a prefix (folder)."""
|
|
233
|
+
mock_settings.google_cloud_project = "test-project"
|
|
234
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
235
|
+
|
|
236
|
+
# Create mock blobs
|
|
237
|
+
mock_blob1 = Mock()
|
|
238
|
+
mock_blob1.name = "uploads/job123/file1.flac"
|
|
239
|
+
mock_blob2 = Mock()
|
|
240
|
+
mock_blob2.name = "uploads/job123/file2.flac"
|
|
241
|
+
mock_blob3 = Mock()
|
|
242
|
+
mock_blob3.name = "uploads/job123/subdir/file3.flac"
|
|
243
|
+
|
|
244
|
+
mock_bucket = Mock()
|
|
245
|
+
mock_bucket.list_blobs.return_value = [mock_blob1, mock_blob2, mock_blob3]
|
|
246
|
+
mock_client = Mock()
|
|
247
|
+
mock_client.bucket.return_value = mock_bucket
|
|
248
|
+
mock_client_class.return_value = mock_client
|
|
249
|
+
|
|
250
|
+
service = StorageService()
|
|
251
|
+
count = service.delete_folder("uploads/job123/")
|
|
252
|
+
|
|
253
|
+
mock_bucket.list_blobs.assert_called_once_with(prefix="uploads/job123/")
|
|
254
|
+
mock_blob1.delete.assert_called_once()
|
|
255
|
+
mock_blob2.delete.assert_called_once()
|
|
256
|
+
mock_blob3.delete.assert_called_once()
|
|
257
|
+
assert count == 3
|
|
258
|
+
|
|
259
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
260
|
+
@patch("backend.services.storage_service.settings")
|
|
261
|
+
def test_delete_folder_handles_errors_gracefully(self, mock_settings, mock_client_class):
|
|
262
|
+
"""Test delete_folder continues even if some deletes fail."""
|
|
263
|
+
mock_settings.google_cloud_project = "test-project"
|
|
264
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
265
|
+
|
|
266
|
+
mock_blob1 = Mock()
|
|
267
|
+
mock_blob1.name = "file1.flac"
|
|
268
|
+
mock_blob2 = Mock()
|
|
269
|
+
mock_blob2.name = "file2.flac"
|
|
270
|
+
mock_blob2.delete.side_effect = Exception("Permission denied")
|
|
271
|
+
mock_blob3 = Mock()
|
|
272
|
+
mock_blob3.name = "file3.flac"
|
|
273
|
+
|
|
274
|
+
mock_bucket = Mock()
|
|
275
|
+
mock_bucket.list_blobs.return_value = [mock_blob1, mock_blob2, mock_blob3]
|
|
276
|
+
mock_client = Mock()
|
|
277
|
+
mock_client.bucket.return_value = mock_bucket
|
|
278
|
+
mock_client_class.return_value = mock_client
|
|
279
|
+
|
|
280
|
+
service = StorageService()
|
|
281
|
+
count = service.delete_folder("uploads/")
|
|
282
|
+
|
|
283
|
+
# Should have deleted 2 files (blob2 failed)
|
|
284
|
+
assert count == 2
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class TestStorageServiceFileOperations:
|
|
288
|
+
"""Test file listing and existence checks."""
|
|
289
|
+
|
|
290
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
291
|
+
@patch("backend.services.storage_service.settings")
|
|
292
|
+
def test_list_files(self, mock_settings, mock_client_class):
|
|
293
|
+
"""Test listing files with a prefix."""
|
|
294
|
+
mock_settings.google_cloud_project = "test-project"
|
|
295
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
296
|
+
|
|
297
|
+
mock_blob1 = Mock()
|
|
298
|
+
mock_blob1.name = "uploads/job123/audio.flac"
|
|
299
|
+
mock_blob2 = Mock()
|
|
300
|
+
mock_blob2.name = "uploads/job123/lyrics.txt"
|
|
301
|
+
|
|
302
|
+
mock_bucket = Mock()
|
|
303
|
+
mock_bucket.list_blobs.return_value = [mock_blob1, mock_blob2]
|
|
304
|
+
mock_client = Mock()
|
|
305
|
+
mock_client.bucket.return_value = mock_bucket
|
|
306
|
+
mock_client_class.return_value = mock_client
|
|
307
|
+
|
|
308
|
+
service = StorageService()
|
|
309
|
+
files = service.list_files("uploads/job123/")
|
|
310
|
+
|
|
311
|
+
assert files == ["uploads/job123/audio.flac", "uploads/job123/lyrics.txt"]
|
|
312
|
+
|
|
313
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
314
|
+
@patch("backend.services.storage_service.settings")
|
|
315
|
+
def test_file_exists_returns_true(self, mock_settings, mock_client_class):
|
|
316
|
+
"""Test file_exists returns True for existing file."""
|
|
317
|
+
mock_settings.google_cloud_project = "test-project"
|
|
318
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
319
|
+
|
|
320
|
+
mock_blob = Mock()
|
|
321
|
+
mock_blob.exists.return_value = True
|
|
322
|
+
mock_bucket = Mock()
|
|
323
|
+
mock_bucket.blob.return_value = mock_blob
|
|
324
|
+
mock_client = Mock()
|
|
325
|
+
mock_client.bucket.return_value = mock_bucket
|
|
326
|
+
mock_client_class.return_value = mock_client
|
|
327
|
+
|
|
328
|
+
service = StorageService()
|
|
329
|
+
result = service.file_exists("uploads/job123/file.flac")
|
|
330
|
+
|
|
331
|
+
assert result is True
|
|
332
|
+
|
|
333
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
334
|
+
@patch("backend.services.storage_service.settings")
|
|
335
|
+
def test_file_exists_returns_false(self, mock_settings, mock_client_class):
|
|
336
|
+
"""Test file_exists returns False for missing file."""
|
|
337
|
+
mock_settings.google_cloud_project = "test-project"
|
|
338
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
339
|
+
|
|
340
|
+
mock_blob = Mock()
|
|
341
|
+
mock_blob.exists.return_value = False
|
|
342
|
+
mock_bucket = Mock()
|
|
343
|
+
mock_bucket.blob.return_value = mock_blob
|
|
344
|
+
mock_client = Mock()
|
|
345
|
+
mock_client.bucket.return_value = mock_bucket
|
|
346
|
+
mock_client_class.return_value = mock_client
|
|
347
|
+
|
|
348
|
+
service = StorageService()
|
|
349
|
+
result = service.file_exists("missing/file.flac")
|
|
350
|
+
|
|
351
|
+
assert result is False
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class TestStorageServiceSignedUrls:
|
|
355
|
+
"""Test signed URL generation."""
|
|
356
|
+
|
|
357
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
358
|
+
@patch("backend.services.storage_service.settings")
|
|
359
|
+
def test_generate_signed_url(self, mock_settings, mock_client_class):
|
|
360
|
+
"""Test generating a signed download URL."""
|
|
361
|
+
mock_settings.google_cloud_project = "test-project"
|
|
362
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
363
|
+
|
|
364
|
+
mock_blob = Mock()
|
|
365
|
+
mock_blob.generate_signed_url.return_value = "https://storage.googleapis.com/signed-url"
|
|
366
|
+
mock_bucket = Mock()
|
|
367
|
+
mock_bucket.blob.return_value = mock_blob
|
|
368
|
+
mock_client = Mock()
|
|
369
|
+
mock_client.bucket.return_value = mock_bucket
|
|
370
|
+
mock_client_class.return_value = mock_client
|
|
371
|
+
|
|
372
|
+
# Mock google.auth.default to return credentials without service_account_email
|
|
373
|
+
with patch("google.auth.default") as mock_auth_default:
|
|
374
|
+
mock_credentials = Mock(spec=[]) # No service_account_email attr
|
|
375
|
+
mock_auth_default.return_value = (mock_credentials, "test-project")
|
|
376
|
+
|
|
377
|
+
service = StorageService()
|
|
378
|
+
url = service.generate_signed_url("uploads/file.flac", expiration_minutes=30)
|
|
379
|
+
|
|
380
|
+
assert url == "https://storage.googleapis.com/signed-url"
|
|
381
|
+
mock_blob.generate_signed_url.assert_called_once()
|
|
382
|
+
call_kwargs = mock_blob.generate_signed_url.call_args.kwargs
|
|
383
|
+
assert call_kwargs["method"] == "GET"
|
|
384
|
+
assert call_kwargs["version"] == "v4"
|
|
385
|
+
|
|
386
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
387
|
+
@patch("backend.services.storage_service.settings")
|
|
388
|
+
def test_generate_signed_upload_url(self, mock_settings, mock_client_class):
|
|
389
|
+
"""Test generating a signed upload URL."""
|
|
390
|
+
mock_settings.google_cloud_project = "test-project"
|
|
391
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
392
|
+
|
|
393
|
+
mock_blob = Mock()
|
|
394
|
+
mock_blob.generate_signed_url.return_value = "https://storage.googleapis.com/signed-upload-url"
|
|
395
|
+
mock_bucket = Mock()
|
|
396
|
+
mock_bucket.blob.return_value = mock_blob
|
|
397
|
+
mock_client = Mock()
|
|
398
|
+
mock_client.bucket.return_value = mock_bucket
|
|
399
|
+
mock_client_class.return_value = mock_client
|
|
400
|
+
|
|
401
|
+
with patch("google.auth.default") as mock_auth_default:
|
|
402
|
+
mock_credentials = Mock(spec=[])
|
|
403
|
+
mock_auth_default.return_value = (mock_credentials, "test-project")
|
|
404
|
+
|
|
405
|
+
service = StorageService()
|
|
406
|
+
url = service.generate_signed_upload_url(
|
|
407
|
+
"uploads/file.flac",
|
|
408
|
+
content_type="audio/flac",
|
|
409
|
+
expiration_minutes=15
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
assert url == "https://storage.googleapis.com/signed-upload-url"
|
|
413
|
+
call_kwargs = mock_blob.generate_signed_url.call_args.kwargs
|
|
414
|
+
assert call_kwargs["method"] == "PUT"
|
|
415
|
+
assert call_kwargs["headers"] == {"Content-Type": "audio/flac"}
|
|
416
|
+
|
|
417
|
+
@patch("backend.services.storage_service.storage.Client")
|
|
418
|
+
@patch("backend.services.storage_service.settings")
|
|
419
|
+
def test_signed_url_with_service_account(self, mock_settings, mock_client_class):
|
|
420
|
+
"""Test signed URL generation with service account credentials."""
|
|
421
|
+
mock_settings.google_cloud_project = "test-project"
|
|
422
|
+
mock_settings.gcs_bucket_name = "test-bucket"
|
|
423
|
+
|
|
424
|
+
mock_blob = Mock()
|
|
425
|
+
mock_blob.generate_signed_url.return_value = "https://storage.googleapis.com/signed-url"
|
|
426
|
+
mock_bucket = Mock()
|
|
427
|
+
mock_bucket.blob.return_value = mock_blob
|
|
428
|
+
mock_client = Mock()
|
|
429
|
+
mock_client.bucket.return_value = mock_bucket
|
|
430
|
+
mock_client_class.return_value = mock_client
|
|
431
|
+
|
|
432
|
+
with patch("google.auth.default") as mock_auth_default:
|
|
433
|
+
with patch("google.auth.transport.requests.Request") as mock_request:
|
|
434
|
+
# Mock credentials with service_account_email
|
|
435
|
+
mock_credentials = Mock()
|
|
436
|
+
mock_credentials.service_account_email = "sa@project.iam.gserviceaccount.com"
|
|
437
|
+
mock_credentials.token = "access-token-123"
|
|
438
|
+
mock_auth_default.return_value = (mock_credentials, "test-project")
|
|
439
|
+
|
|
440
|
+
service = StorageService()
|
|
441
|
+
url = service.generate_signed_url("uploads/file.flac")
|
|
442
|
+
|
|
443
|
+
# Should have refreshed credentials and used IAM signing
|
|
444
|
+
mock_credentials.refresh.assert_called_once()
|
|
445
|
+
call_kwargs = mock_blob.generate_signed_url.call_args.kwargs
|
|
446
|
+
assert call_kwargs["service_account_email"] == "sa@project.iam.gserviceaccount.com"
|
|
447
|
+
assert call_kwargs["access_token"] == "access-token-123"
|
|
448
|
+
|