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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,472 @@
1
+ """
2
+ Tests for dropbox_service.py - Dropbox file operations.
3
+
4
+ These tests mock the Dropbox SDK and Secret Manager to verify:
5
+ - Credential loading from Secret Manager
6
+ - Folder listing and brand code calculation
7
+ - File and folder uploads
8
+ - Shared link creation
9
+ """
10
+ import json
11
+ import os
12
+ import pytest
13
+ from unittest.mock import Mock, MagicMock, patch
14
+
15
+
16
+ class TestDropboxServiceInit:
17
+ """Test DropboxService initialization."""
18
+
19
+ def test_init_creates_service(self):
20
+ """Test initialization creates service with no client."""
21
+ from backend.services.dropbox_service import DropboxService
22
+
23
+ service = DropboxService()
24
+
25
+ assert service._client is None
26
+ assert service._is_configured is False
27
+
28
+
29
+ class TestLoadCredentials:
30
+ """Test _load_credentials method."""
31
+
32
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
33
+ def test_load_credentials_success(self, mock_sm_client_class):
34
+ """Test successful credential loading from Secret Manager."""
35
+ from backend.services.dropbox_service import DropboxService
36
+
37
+ mock_sm_client = Mock()
38
+ mock_response = Mock()
39
+ mock_response.payload.data = json.dumps({
40
+ "access_token": "access-token-123",
41
+ "refresh_token": "refresh-token-456",
42
+ "app_key": "app-key",
43
+ "app_secret": "app-secret",
44
+ }).encode("UTF-8")
45
+ mock_sm_client.access_secret_version.return_value = mock_response
46
+ mock_sm_client_class.return_value = mock_sm_client
47
+
48
+ service = DropboxService()
49
+ creds = service._load_credentials()
50
+
51
+ assert creds is not None
52
+ assert creds["access_token"] == "access-token-123"
53
+
54
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
55
+ def test_load_credentials_failure(self, mock_sm_client_class):
56
+ """Test handling when Secret Manager fails."""
57
+ from backend.services.dropbox_service import DropboxService
58
+
59
+ mock_sm_client = Mock()
60
+ mock_sm_client.access_secret_version.side_effect = Exception("Access denied")
61
+ mock_sm_client_class.return_value = mock_sm_client
62
+
63
+ service = DropboxService()
64
+ creds = service._load_credentials()
65
+
66
+ assert creds is None
67
+
68
+
69
+ class TestIsConfigured:
70
+ """Test is_configured property."""
71
+
72
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
73
+ def test_is_configured_true(self, mock_sm_client_class):
74
+ """Test is_configured returns True when credentials available."""
75
+ from backend.services.dropbox_service import DropboxService
76
+
77
+ mock_sm_client = Mock()
78
+ mock_response = Mock()
79
+ mock_response.payload.data = json.dumps({
80
+ "access_token": "token"
81
+ }).encode("UTF-8")
82
+ mock_sm_client.access_secret_version.return_value = mock_response
83
+ mock_sm_client_class.return_value = mock_sm_client
84
+
85
+ service = DropboxService()
86
+
87
+ assert service.is_configured is True
88
+
89
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
90
+ def test_is_configured_false_no_token(self, mock_sm_client_class):
91
+ """Test is_configured returns False when no access_token."""
92
+ from backend.services.dropbox_service import DropboxService
93
+
94
+ mock_sm_client = Mock()
95
+ mock_response = Mock()
96
+ mock_response.payload.data = json.dumps({
97
+ "refresh_token": "refresh" # Missing access_token
98
+ }).encode("UTF-8")
99
+ mock_sm_client.access_secret_version.return_value = mock_response
100
+ mock_sm_client_class.return_value = mock_sm_client
101
+
102
+ service = DropboxService()
103
+
104
+ assert service.is_configured is False
105
+
106
+
107
+ class TestDropboxClient:
108
+ """Test client property."""
109
+
110
+ @patch("dropbox.Dropbox")
111
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
112
+ def test_client_creates_dropbox_instance(self, mock_sm_client_class, mock_dropbox_class):
113
+ """Test client property creates Dropbox SDK client."""
114
+ from backend.services.dropbox_service import DropboxService
115
+
116
+ mock_sm_client = Mock()
117
+ mock_response = Mock()
118
+ mock_response.payload.data = json.dumps({
119
+ "access_token": "token",
120
+ "refresh_token": "refresh",
121
+ "app_key": "key",
122
+ "app_secret": "secret",
123
+ }).encode("UTF-8")
124
+ mock_sm_client.access_secret_version.return_value = mock_response
125
+ mock_sm_client_class.return_value = mock_sm_client
126
+
127
+ mock_dropbox = Mock()
128
+ mock_dropbox_class.return_value = mock_dropbox
129
+
130
+ service = DropboxService()
131
+ client = service.client
132
+
133
+ mock_dropbox_class.assert_called_once_with(
134
+ oauth2_access_token="token",
135
+ oauth2_refresh_token="refresh",
136
+ app_key="key",
137
+ app_secret="secret",
138
+ )
139
+ assert client == mock_dropbox
140
+
141
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
142
+ def test_client_raises_on_missing_credentials(self, mock_sm_client_class):
143
+ """Test client raises RuntimeError when credentials missing."""
144
+ from backend.services.dropbox_service import DropboxService
145
+
146
+ mock_sm_client = Mock()
147
+ mock_sm_client.access_secret_version.side_effect = Exception("Not found")
148
+ mock_sm_client_class.return_value = mock_sm_client
149
+
150
+ service = DropboxService()
151
+
152
+ with pytest.raises(RuntimeError) as exc_info:
153
+ _ = service.client
154
+
155
+ assert "not configured" in str(exc_info.value)
156
+
157
+
158
+ class TestListFolders:
159
+ """Test list_folders method."""
160
+
161
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
162
+ def test_list_folders(self, mock_sm_client_class):
163
+ """Test listing folders at a path."""
164
+ from backend.services.dropbox_service import DropboxService
165
+ from dropbox.files import FolderMetadata
166
+
167
+ mock_sm_client = Mock()
168
+ mock_response = Mock()
169
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
170
+ mock_sm_client.access_secret_version.return_value = mock_response
171
+ mock_sm_client_class.return_value = mock_sm_client
172
+
173
+ # Create mock folder entries
174
+ mock_folder1 = Mock(spec=FolderMetadata)
175
+ mock_folder1.name = "NOMAD-0001"
176
+ mock_folder2 = Mock(spec=FolderMetadata)
177
+ mock_folder2.name = "NOMAD-0002"
178
+ mock_file = Mock() # Not a FolderMetadata
179
+
180
+ mock_result = Mock()
181
+ mock_result.entries = [mock_folder1, mock_folder2, mock_file]
182
+ mock_result.has_more = False
183
+
184
+ service = DropboxService()
185
+ # Directly set the client to avoid needing to mock the whole init chain
186
+ mock_dropbox = Mock()
187
+ mock_dropbox.files_list_folder.return_value = mock_result
188
+ service._client = mock_dropbox
189
+
190
+ folders = service.list_folders("/Karaoke/Tracks")
191
+
192
+ mock_dropbox.files_list_folder.assert_called_once_with("/Karaoke/Tracks")
193
+ assert folders == ["NOMAD-0001", "NOMAD-0002"]
194
+
195
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
196
+ def test_list_folders_adds_leading_slash(self, mock_sm_client_class):
197
+ """Test that path without leading slash gets one added."""
198
+ from backend.services.dropbox_service import DropboxService
199
+
200
+ mock_sm_client = Mock()
201
+ mock_response = Mock()
202
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
203
+ mock_sm_client.access_secret_version.return_value = mock_response
204
+ mock_sm_client_class.return_value = mock_sm_client
205
+
206
+ mock_result = Mock()
207
+ mock_result.entries = []
208
+ mock_result.has_more = False
209
+
210
+ service = DropboxService()
211
+ mock_dropbox = Mock()
212
+ mock_dropbox.files_list_folder.return_value = mock_result
213
+ service._client = mock_dropbox
214
+
215
+ service.list_folders("path/without/slash")
216
+
217
+ mock_dropbox.files_list_folder.assert_called_once_with("/path/without/slash")
218
+
219
+
220
+ class TestGetNextBrandCode:
221
+ """Test get_next_brand_code method."""
222
+
223
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
224
+ def test_get_next_brand_code(self, mock_sm_client_class):
225
+ """Test calculating next brand code from existing folders."""
226
+ from backend.services.dropbox_service import DropboxService
227
+
228
+ mock_sm_client = Mock()
229
+ mock_response = Mock()
230
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
231
+ mock_sm_client.access_secret_version.return_value = mock_response
232
+ mock_sm_client_class.return_value = mock_sm_client
233
+
234
+ service = DropboxService()
235
+
236
+ # Mock list_folders to return existing codes
237
+ with patch.object(service, "list_folders") as mock_list:
238
+ mock_list.return_value = [
239
+ "NOMAD-1161",
240
+ "NOMAD-1162",
241
+ "NOMAD-1163",
242
+ "Other Folder",
243
+ "NOMAD-0001",
244
+ ]
245
+
246
+ next_code = service.get_next_brand_code("/path", "NOMAD")
247
+
248
+ assert next_code == "NOMAD-1164"
249
+
250
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
251
+ def test_get_next_brand_code_empty_folder(self, mock_sm_client_class):
252
+ """Test brand code calculation with no existing codes."""
253
+ from backend.services.dropbox_service import DropboxService
254
+
255
+ mock_sm_client = Mock()
256
+ mock_response = Mock()
257
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
258
+ mock_sm_client.access_secret_version.return_value = mock_response
259
+ mock_sm_client_class.return_value = mock_sm_client
260
+
261
+ service = DropboxService()
262
+
263
+ with patch.object(service, "list_folders") as mock_list:
264
+ mock_list.return_value = []
265
+
266
+ next_code = service.get_next_brand_code("/path", "BRAND")
267
+
268
+ assert next_code == "BRAND-0001"
269
+
270
+
271
+ class TestUploadFile:
272
+ """Test upload_file method."""
273
+
274
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
275
+ def test_upload_small_file(self, mock_sm_client_class, tmp_path):
276
+ """Test uploading a small file directly."""
277
+ from backend.services.dropbox_service import DropboxService
278
+
279
+ mock_sm_client = Mock()
280
+ mock_response = Mock()
281
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
282
+ mock_sm_client.access_secret_version.return_value = mock_response
283
+ mock_sm_client_class.return_value = mock_sm_client
284
+
285
+ # Create test file
286
+ test_file = tmp_path / "test.txt"
287
+ test_file.write_text("Small file content")
288
+
289
+ service = DropboxService()
290
+ mock_dropbox = Mock()
291
+ service._client = mock_dropbox
292
+
293
+ service.upload_file(str(test_file), "/Uploads/test.txt")
294
+
295
+ mock_dropbox.files_upload.assert_called_once()
296
+
297
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
298
+ def test_upload_file_adds_leading_slash(self, mock_sm_client_class, tmp_path):
299
+ """Test upload adds leading slash to remote path."""
300
+ from backend.services.dropbox_service import DropboxService
301
+
302
+ mock_sm_client = Mock()
303
+ mock_response = Mock()
304
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
305
+ mock_sm_client.access_secret_version.return_value = mock_response
306
+ mock_sm_client_class.return_value = mock_sm_client
307
+
308
+ test_file = tmp_path / "test.txt"
309
+ test_file.write_text("content")
310
+
311
+ service = DropboxService()
312
+ mock_dropbox = Mock()
313
+ service._client = mock_dropbox
314
+
315
+ service.upload_file(str(test_file), "uploads/test.txt")
316
+
317
+ # Check that the path has leading slash
318
+ call_args = mock_dropbox.files_upload.call_args
319
+ assert call_args[0][1] == "/uploads/test.txt"
320
+
321
+
322
+ class TestUploadFolder:
323
+ """Test upload_folder method."""
324
+
325
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
326
+ def test_upload_folder(self, mock_sm_client_class, tmp_path):
327
+ """Test uploading a folder with multiple files."""
328
+ from backend.services.dropbox_service import DropboxService
329
+
330
+ mock_sm_client = Mock()
331
+ mock_response = Mock()
332
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
333
+ mock_sm_client.access_secret_version.return_value = mock_response
334
+ mock_sm_client_class.return_value = mock_sm_client
335
+
336
+ # Create test folder with files (no subdirs for this test)
337
+ (tmp_path / "file1.txt").write_text("content1")
338
+ (tmp_path / "file2.txt").write_text("content2")
339
+
340
+ service = DropboxService()
341
+
342
+ with patch.object(service, "upload_file") as mock_upload:
343
+ service.upload_folder(str(tmp_path), "/Uploads/folder")
344
+
345
+ # Should upload 2 files
346
+ assert mock_upload.call_count == 2
347
+
348
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
349
+ def test_upload_folder_recursive(self, mock_sm_client_class, tmp_path):
350
+ """Test uploading a folder recursively includes subdirectories."""
351
+ from backend.services.dropbox_service import DropboxService
352
+
353
+ mock_sm_client = Mock()
354
+ mock_response = Mock()
355
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
356
+ mock_sm_client.access_secret_version.return_value = mock_response
357
+ mock_sm_client_class.return_value = mock_sm_client
358
+
359
+ # Create test folder with files and subdirectories
360
+ (tmp_path / "root_file.txt").write_text("root content")
361
+ (tmp_path / "stems").mkdir()
362
+ (tmp_path / "stems" / "vocals.flac").write_text("vocals")
363
+ (tmp_path / "stems" / "instrumental.flac").write_text("instrumental")
364
+ (tmp_path / "lyrics").mkdir()
365
+ (tmp_path / "lyrics" / "song.lrc").write_text("lyrics")
366
+
367
+ service = DropboxService()
368
+
369
+ uploaded_files = []
370
+ def capture_upload(local_path, remote_path):
371
+ uploaded_files.append(remote_path)
372
+
373
+ with patch.object(service, "upload_file", side_effect=capture_upload) as mock_upload:
374
+ service.upload_folder(str(tmp_path), "/Uploads/folder")
375
+
376
+ # Should upload 4 files (1 root + 2 stems + 1 lyrics)
377
+ assert mock_upload.call_count == 4
378
+
379
+ # Check that subdirectory structure is preserved
380
+ assert "/Uploads/folder/root_file.txt" in uploaded_files
381
+ assert "/Uploads/folder/stems/vocals.flac" in uploaded_files
382
+ assert "/Uploads/folder/stems/instrumental.flac" in uploaded_files
383
+ assert "/Uploads/folder/lyrics/song.lrc" in uploaded_files
384
+
385
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
386
+ def test_upload_folder_deeply_nested(self, mock_sm_client_class, tmp_path):
387
+ """Test uploading deeply nested folder structure."""
388
+ from backend.services.dropbox_service import DropboxService
389
+
390
+ mock_sm_client = Mock()
391
+ mock_response = Mock()
392
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
393
+ mock_sm_client.access_secret_version.return_value = mock_response
394
+ mock_sm_client_class.return_value = mock_sm_client
395
+
396
+ # Create deeply nested structure
397
+ (tmp_path / "level1").mkdir()
398
+ (tmp_path / "level1" / "level2").mkdir()
399
+ (tmp_path / "level1" / "level2" / "deep_file.txt").write_text("deep")
400
+
401
+ service = DropboxService()
402
+
403
+ uploaded_files = []
404
+ def capture_upload(local_path, remote_path):
405
+ uploaded_files.append(remote_path)
406
+
407
+ with patch.object(service, "upload_file", side_effect=capture_upload):
408
+ service.upload_folder(str(tmp_path), "/Uploads/folder")
409
+
410
+ # Check deeply nested file is uploaded with correct path
411
+ assert "/Uploads/folder/level1/level2/deep_file.txt" in uploaded_files
412
+
413
+
414
+ class TestCreateSharedLink:
415
+ """Test create_shared_link method."""
416
+
417
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
418
+ def test_create_shared_link_new(self, mock_sm_client_class):
419
+ """Test creating a new shared link."""
420
+ from backend.services.dropbox_service import DropboxService
421
+
422
+ mock_sm_client = Mock()
423
+ mock_response = Mock()
424
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
425
+ mock_sm_client.access_secret_version.return_value = mock_response
426
+ mock_sm_client_class.return_value = mock_sm_client
427
+
428
+ mock_link = Mock()
429
+ mock_link.url = "https://dropbox.com/s/abc123/file.mp4"
430
+
431
+ service = DropboxService()
432
+ mock_dropbox = Mock()
433
+ mock_dropbox.sharing_create_shared_link_with_settings.return_value = mock_link
434
+ service._client = mock_dropbox
435
+
436
+ url = service.create_shared_link("/Uploads/file.mp4")
437
+
438
+ assert url == "https://dropbox.com/s/abc123/file.mp4"
439
+
440
+ @patch("backend.services.dropbox_service.secretmanager.SecretManagerServiceClient")
441
+ def test_create_shared_link_existing(self, mock_sm_client_class):
442
+ """Test getting existing shared link when one already exists."""
443
+ from backend.services.dropbox_service import DropboxService
444
+ from dropbox.exceptions import ApiError
445
+
446
+ mock_sm_client = Mock()
447
+ mock_response = Mock()
448
+ mock_response.payload.data = json.dumps({"access_token": "token"}).encode()
449
+ mock_sm_client.access_secret_version.return_value = mock_response
450
+ mock_sm_client_class.return_value = mock_sm_client
451
+
452
+ # Simulate "link already exists" error
453
+ mock_error = Mock()
454
+ mock_error.is_shared_link_already_exists.return_value = True
455
+
456
+ mock_existing_link = Mock()
457
+ mock_existing_link.url = "https://dropbox.com/s/existing/file.mp4"
458
+
459
+ mock_links_result = Mock()
460
+ mock_links_result.links = [mock_existing_link]
461
+
462
+ service = DropboxService()
463
+ mock_dropbox = Mock()
464
+ mock_dropbox.sharing_create_shared_link_with_settings.side_effect = \
465
+ ApiError("req_id", mock_error, "message", "headers")
466
+ mock_dropbox.sharing_list_shared_links.return_value = mock_links_result
467
+ service._client = mock_dropbox
468
+
469
+ url = service.create_shared_link("/Uploads/file.mp4")
470
+
471
+ assert url == "https://dropbox.com/s/existing/file.mp4"
472
+