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,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
+