karaoke-gen 0.86.7__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 (188) 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/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,377 @@
1
+ """
2
+ Tests for credential manager and auth endpoints.
3
+ """
4
+ import pytest
5
+ import json
6
+ from unittest.mock import MagicMock, patch
7
+ from datetime import datetime
8
+
9
+ from backend.services.credential_manager import (
10
+ CredentialManager,
11
+ CredentialStatus,
12
+ CredentialCheckResult,
13
+ DeviceAuthInfo,
14
+ get_credential_manager,
15
+ )
16
+
17
+
18
+ class TestCredentialStatus:
19
+ """Tests for CredentialStatus enum."""
20
+
21
+ def test_status_values(self):
22
+ """Test all status values exist."""
23
+ assert CredentialStatus.VALID == "valid"
24
+ assert CredentialStatus.EXPIRED == "expired"
25
+ assert CredentialStatus.INVALID == "invalid"
26
+ assert CredentialStatus.NOT_CONFIGURED == "not_configured"
27
+ assert CredentialStatus.ERROR == "error"
28
+
29
+
30
+ class TestCredentialCheckResult:
31
+ """Tests for CredentialCheckResult dataclass."""
32
+
33
+ def test_create_result(self):
34
+ """Test creating a check result."""
35
+ result = CredentialCheckResult(
36
+ service="youtube",
37
+ status=CredentialStatus.VALID,
38
+ message="Credentials are valid",
39
+ last_checked=datetime.utcnow()
40
+ )
41
+
42
+ assert result.service == "youtube"
43
+ assert result.status == CredentialStatus.VALID
44
+ assert result.message == "Credentials are valid"
45
+ assert result.expires_at is None
46
+
47
+ def test_create_result_with_expiry(self):
48
+ """Test creating a check result with expiry."""
49
+ expiry = datetime.utcnow()
50
+ result = CredentialCheckResult(
51
+ service="gdrive",
52
+ status=CredentialStatus.VALID,
53
+ message="Valid",
54
+ last_checked=datetime.utcnow(),
55
+ expires_at=expiry
56
+ )
57
+
58
+ assert result.expires_at == expiry
59
+
60
+
61
+ class TestCredentialManager:
62
+ """Tests for CredentialManager class."""
63
+
64
+ def test_init(self):
65
+ """Test manager initialization."""
66
+ manager = CredentialManager()
67
+ assert manager._pending_device_auths == {}
68
+
69
+ def test_check_youtube_not_configured(self):
70
+ """Test YouTube check when not configured."""
71
+ with patch('backend.services.credential_manager.get_settings') as mock_get_settings:
72
+ mock_settings = MagicMock()
73
+ mock_settings.get_secret.return_value = None
74
+ mock_get_settings.return_value = mock_settings
75
+
76
+ manager = CredentialManager()
77
+ result = manager.check_youtube_credentials()
78
+
79
+ assert result.status == CredentialStatus.NOT_CONFIGURED
80
+ assert "not configured" in result.message.lower()
81
+
82
+ def test_check_youtube_invalid_json(self):
83
+ """Test YouTube check with invalid JSON."""
84
+ with patch('backend.services.credential_manager.get_settings') as mock_get_settings:
85
+ mock_settings = MagicMock()
86
+ mock_settings.get_secret.return_value = "not valid json"
87
+ mock_get_settings.return_value = mock_settings
88
+
89
+ manager = CredentialManager()
90
+ result = manager.check_youtube_credentials()
91
+
92
+ assert result.status == CredentialStatus.INVALID
93
+ assert "json" in result.message.lower()
94
+
95
+ def test_check_youtube_missing_fields(self):
96
+ """Test YouTube check with missing required fields."""
97
+ # Missing refresh_token and client_secret
98
+ creds = json.dumps({"token": "test", "client_id": "test"})
99
+
100
+ with patch('backend.services.credential_manager.get_settings') as mock_get_settings:
101
+ mock_settings = MagicMock()
102
+ mock_settings.get_secret.return_value = creds
103
+ mock_get_settings.return_value = mock_settings
104
+
105
+ manager = CredentialManager()
106
+ result = manager.check_youtube_credentials()
107
+
108
+ assert result.status == CredentialStatus.INVALID
109
+ assert "missing" in result.message.lower()
110
+
111
+ def test_check_gdrive_not_configured(self):
112
+ """Test Google Drive check when not configured."""
113
+ with patch('backend.services.credential_manager.get_settings') as mock_get_settings:
114
+ mock_settings = MagicMock()
115
+ mock_settings.get_secret.return_value = None
116
+ mock_get_settings.return_value = mock_settings
117
+
118
+ manager = CredentialManager()
119
+ result = manager.check_gdrive_credentials()
120
+
121
+ assert result.status == CredentialStatus.NOT_CONFIGURED
122
+
123
+ def test_check_dropbox_not_configured(self):
124
+ """Test Dropbox check when not configured."""
125
+ with patch('backend.services.credential_manager.get_settings') as mock_get_settings:
126
+ mock_settings = MagicMock()
127
+ mock_settings.get_secret.return_value = None
128
+ mock_get_settings.return_value = mock_settings
129
+
130
+ manager = CredentialManager()
131
+ result = manager.check_dropbox_credentials()
132
+
133
+ assert result.status == CredentialStatus.NOT_CONFIGURED
134
+
135
+ def test_check_dropbox_missing_access_token(self):
136
+ """Test Dropbox check with missing access token."""
137
+ creds = json.dumps({"refresh_token": "test"}) # No access_token
138
+
139
+ with patch('backend.services.credential_manager.get_settings') as mock_get_settings:
140
+ mock_settings = MagicMock()
141
+ mock_settings.get_secret.return_value = creds
142
+ mock_get_settings.return_value = mock_settings
143
+
144
+ manager = CredentialManager()
145
+ result = manager.check_dropbox_credentials()
146
+
147
+ assert result.status == CredentialStatus.INVALID
148
+ assert "access_token" in result.message.lower()
149
+
150
+ def test_check_all_credentials(self):
151
+ """Test checking all credentials."""
152
+ with patch('backend.services.credential_manager.get_settings') as mock_get_settings:
153
+ mock_settings = MagicMock()
154
+ mock_settings.get_secret.return_value = None
155
+ mock_get_settings.return_value = mock_settings
156
+
157
+ manager = CredentialManager()
158
+ results = manager.check_all_credentials()
159
+
160
+ assert "youtube" in results
161
+ assert "gdrive" in results
162
+ assert "dropbox" in results
163
+ assert all(r.status == CredentialStatus.NOT_CONFIGURED for r in results.values())
164
+
165
+ def test_test_youtube_api_failure(self):
166
+ """Test YouTube API test failure."""
167
+ manager = CredentialManager()
168
+
169
+ mock_creds = MagicMock()
170
+
171
+ with patch('googleapiclient.discovery.build', side_effect=Exception("API Error")):
172
+ result = manager._test_youtube_api(mock_creds)
173
+
174
+ assert result is False
175
+
176
+ def test_test_gdrive_api_failure(self):
177
+ """Test Google Drive API test failure."""
178
+ manager = CredentialManager()
179
+
180
+ mock_creds = MagicMock()
181
+
182
+ with patch('googleapiclient.discovery.build', side_effect=Exception("API Error")):
183
+ result = manager._test_gdrive_api(mock_creds)
184
+
185
+ assert result is False
186
+
187
+ def test_test_dropbox_api_failure(self):
188
+ """Test Dropbox API test failure."""
189
+ manager = CredentialManager()
190
+
191
+ with patch('dropbox.Dropbox', side_effect=Exception("API Error")):
192
+ result = manager._test_dropbox_api({"access_token": "test"})
193
+
194
+ assert result is False
195
+
196
+ def test_send_credential_alert_no_webhook(self):
197
+ """Test alert without webhook URL."""
198
+ manager = CredentialManager()
199
+
200
+ result = manager.send_credential_alert([], discord_webhook_url=None)
201
+
202
+ assert result is False
203
+
204
+ def test_send_credential_alert_success(self):
205
+ """Test successful alert sending."""
206
+ manager = CredentialManager()
207
+
208
+ mock_response = MagicMock()
209
+ mock_response.status_code = 200
210
+
211
+ invalid_services = [
212
+ CredentialCheckResult(
213
+ service="youtube",
214
+ status=CredentialStatus.INVALID,
215
+ message="Test message",
216
+ last_checked=datetime.utcnow()
217
+ )
218
+ ]
219
+
220
+ with patch('requests.post', return_value=mock_response) as mock_post:
221
+ result = manager.send_credential_alert(
222
+ invalid_services,
223
+ discord_webhook_url="https://discord.com/webhook/test"
224
+ )
225
+
226
+ assert result is True
227
+ mock_post.assert_called_once()
228
+
229
+ def test_start_youtube_device_auth(self):
230
+ """Test starting YouTube device auth flow."""
231
+ manager = CredentialManager()
232
+
233
+ mock_response = MagicMock()
234
+ mock_response.status_code = 200
235
+ mock_response.json.return_value = {
236
+ "device_code": "test_device_code",
237
+ "user_code": "TEST-CODE",
238
+ "verification_uri": "https://google.com/device",
239
+ "expires_in": 1800,
240
+ "interval": 5
241
+ }
242
+
243
+ with patch('requests.post', return_value=mock_response):
244
+ device_info = manager.start_youtube_device_auth(
245
+ client_id="test_client",
246
+ client_secret="test_secret"
247
+ )
248
+
249
+ assert device_info.device_code == "test_device_code"
250
+ assert device_info.user_code == "TEST-CODE"
251
+ assert device_info.verification_url == "https://google.com/device"
252
+
253
+ # Should be stored for polling
254
+ key = f"youtube:{device_info.device_code}"
255
+ assert key in manager._pending_device_auths
256
+
257
+ def test_poll_device_auth_no_client_creds(self):
258
+ """Test polling when client credentials are not in Secret Manager."""
259
+ manager = CredentialManager()
260
+
261
+ # Mock get_youtube_client_credentials to return None (no creds)
262
+ with patch.object(manager, 'get_youtube_client_credentials', return_value=None):
263
+ status, data = manager.poll_device_auth("youtube", "some_code")
264
+
265
+ assert status == "error"
266
+ assert "client credentials not found" in data["message"].lower()
267
+
268
+ def test_poll_device_auth_expired(self):
269
+ """Test polling with expired device code."""
270
+ manager = CredentialManager()
271
+
272
+ mock_response = MagicMock()
273
+ mock_response.status_code = 400
274
+ mock_response.json.return_value = {"error": "expired_token"}
275
+
276
+ with patch.object(manager, 'get_youtube_client_credentials',
277
+ return_value={"client_id": "test", "client_secret": "test"}):
278
+ with patch('requests.post', return_value=mock_response):
279
+ status, data = manager.poll_device_auth("youtube", "expired_code")
280
+
281
+ assert status == "expired"
282
+ assert "expired" in data["message"].lower()
283
+
284
+ def test_poll_device_auth_pending(self):
285
+ """Test polling when authorization is pending."""
286
+ manager = CredentialManager()
287
+
288
+ mock_response = MagicMock()
289
+ mock_response.status_code = 400
290
+ mock_response.json.return_value = {"error": "authorization_pending"}
291
+
292
+ with patch.object(manager, 'get_youtube_client_credentials',
293
+ return_value={"client_id": "test", "client_secret": "test"}):
294
+ with patch('requests.post', return_value=mock_response):
295
+ status, data = manager.poll_device_auth("youtube", "pending_code")
296
+
297
+ assert status == "pending"
298
+ assert "waiting" in data["message"].lower()
299
+
300
+ def test_poll_device_auth_success(self):
301
+ """Test polling when authorization completes successfully."""
302
+ manager = CredentialManager()
303
+
304
+ mock_response = MagicMock()
305
+ mock_response.status_code = 200
306
+ mock_response.json.return_value = {
307
+ "access_token": "new_access_token",
308
+ "refresh_token": "new_refresh_token"
309
+ }
310
+
311
+ with patch.object(manager, 'get_youtube_client_credentials',
312
+ return_value={"client_id": "test_id", "client_secret": "test_secret"}):
313
+ with patch('requests.post', return_value=mock_response):
314
+ with patch.object(manager, '_save_credentials_to_secret', return_value=True):
315
+ status, data = manager.poll_device_auth("youtube", "completed_code")
316
+
317
+ assert status == "complete"
318
+ assert data["token"] == "new_access_token"
319
+ assert data["refresh_token"] == "new_refresh_token"
320
+ assert data["client_id"] == "test_id"
321
+
322
+
323
+ class TestGetCredentialManager:
324
+ """Tests for singleton factory function."""
325
+
326
+ def test_returns_singleton(self):
327
+ """Test that factory returns same instance."""
328
+ # Reset singleton
329
+ import backend.services.credential_manager as module
330
+ module._credential_manager = None
331
+
332
+ manager1 = get_credential_manager()
333
+ manager2 = get_credential_manager()
334
+
335
+ assert manager1 is manager2
336
+
337
+
338
+ class TestAuthRoutes:
339
+ """Tests for auth API routes."""
340
+
341
+ def test_routes_import(self):
342
+ """Test that routes can be imported."""
343
+ from backend.api.routes.auth import router
344
+ assert router is not None
345
+
346
+ def test_status_endpoint_exists(self):
347
+ """Test that status endpoint is defined."""
348
+ from backend.api.routes.auth import get_credentials_status
349
+ assert get_credentials_status is not None
350
+
351
+ def test_validate_endpoint_exists(self):
352
+ """Test that validate endpoint is defined."""
353
+ from backend.api.routes.auth import validate_credentials
354
+ assert validate_credentials is not None
355
+
356
+ def test_device_auth_endpoint_exists(self):
357
+ """Test that device auth endpoints are defined."""
358
+ from backend.api.routes.auth import (
359
+ start_youtube_device_auth,
360
+ poll_youtube_device_auth,
361
+ start_gdrive_device_auth,
362
+ poll_gdrive_device_auth,
363
+ )
364
+ assert start_youtube_device_auth is not None
365
+ assert poll_youtube_device_auth is not None
366
+ assert start_gdrive_device_auth is not None
367
+ assert poll_gdrive_device_auth is not None
368
+
369
+
370
+ class TestFileUploadCredentialValidation:
371
+ """Tests for credential validation in file upload."""
372
+
373
+ def test_credential_manager_imported(self):
374
+ """Test that credential manager is imported in file upload."""
375
+ from backend.api.routes.file_upload import get_credential_manager, CredentialStatus
376
+ assert get_credential_manager is not None
377
+ assert CredentialStatus is not None
@@ -0,0 +1,54 @@
1
+ """
2
+ Tests for API dependencies (authentication, authorization).
3
+ """
4
+ import pytest
5
+ from unittest.mock import MagicMock, patch
6
+
7
+
8
+ class TestAuthDependencies:
9
+ """Tests for authentication dependencies module structure."""
10
+
11
+ def test_get_token_from_request_function_exists(self):
12
+ """Test get_token_from_request function exists."""
13
+ from backend.api.dependencies import get_token_from_request
14
+ assert get_token_from_request is not None
15
+
16
+ def test_require_auth_function_exists(self):
17
+ """Test require_auth function exists."""
18
+ from backend.api.dependencies import require_auth
19
+ assert require_auth is not None
20
+
21
+ def test_require_admin_function_exists(self):
22
+ """Test require_admin function exists."""
23
+ from backend.api.dependencies import require_admin
24
+ assert require_admin is not None
25
+
26
+ def test_optional_auth_function_exists(self):
27
+ """Test optional_auth function exists."""
28
+ from backend.api.dependencies import optional_auth
29
+ assert optional_auth is not None
30
+
31
+ def test_security_scheme_defined(self):
32
+ """Test HTTP Bearer security scheme is defined."""
33
+ from backend.api.dependencies import security
34
+ assert security is not None
35
+
36
+ def test_logger_configured(self):
37
+ """Test logger is configured."""
38
+ from backend.api.dependencies import logger
39
+ assert logger is not None
40
+
41
+
42
+ class TestAuthServiceAccess:
43
+ """Tests for auth service access."""
44
+
45
+ def test_get_auth_service_function_exists(self):
46
+ """Test get_auth_service function exists."""
47
+ from backend.services.auth_service import get_auth_service
48
+ assert get_auth_service is not None
49
+
50
+ def test_user_type_enum_defined(self):
51
+ """Test UserType enum is defined."""
52
+ from backend.services.auth_service import UserType
53
+ assert UserType is not None
54
+
@@ -0,0 +1,244 @@
1
+ """
2
+ Tests for DiscordNotificationService.
3
+
4
+ Tests cover:
5
+ - Service initialization
6
+ - Webhook URL validation
7
+ - Message posting
8
+ - Video notification posting
9
+ - Dry run mode
10
+ """
11
+
12
+ import pytest
13
+ from unittest.mock import MagicMock, patch
14
+
15
+ from backend.services.discord_service import (
16
+ DiscordNotificationService,
17
+ get_discord_notification_service,
18
+ )
19
+
20
+
21
+ class TestDiscordNotificationServiceInit:
22
+ """Test service initialization."""
23
+
24
+ def test_init_with_webhook_url(self):
25
+ """Test initialization with valid webhook URL."""
26
+ url = "https://discord.com/api/webhooks/123456789/abcdef123456"
27
+ service = DiscordNotificationService(webhook_url=url)
28
+ assert service.webhook_url == url
29
+ assert service.is_enabled() is True
30
+
31
+ def test_init_without_webhook_url(self):
32
+ """Test initialization without webhook URL."""
33
+ service = DiscordNotificationService()
34
+ assert service.webhook_url is None
35
+ assert service.is_enabled() is False
36
+
37
+ def test_init_with_dry_run(self):
38
+ """Test initialization with dry run mode."""
39
+ service = DiscordNotificationService(dry_run=True)
40
+ assert service.dry_run is True
41
+
42
+ def test_init_with_invalid_webhook_url_raises(self):
43
+ """Test that invalid webhook URL raises ValueError."""
44
+ with pytest.raises(ValueError) as exc_info:
45
+ DiscordNotificationService(webhook_url="https://example.com/webhook")
46
+ assert "Invalid Discord webhook URL" in str(exc_info.value)
47
+
48
+ def test_init_with_non_discord_url_raises(self):
49
+ """Test that non-Discord URL raises ValueError."""
50
+ with pytest.raises(ValueError):
51
+ DiscordNotificationService(
52
+ webhook_url="https://slack.com/api/webhooks/123"
53
+ )
54
+
55
+
56
+ class TestDiscordNotificationServiceValidation:
57
+ """Test webhook URL validation."""
58
+
59
+ def test_validate_valid_webhook_url(self):
60
+ """Test that valid webhook URL passes validation."""
61
+ service = DiscordNotificationService()
62
+ # Should not raise
63
+ service._validate_webhook_url(
64
+ "https://discord.com/api/webhooks/123456789/abcdef"
65
+ )
66
+
67
+ def test_validate_invalid_webhook_url(self):
68
+ """Test that invalid webhook URL raises ValueError."""
69
+ service = DiscordNotificationService()
70
+ with pytest.raises(ValueError) as exc_info:
71
+ service._validate_webhook_url("https://example.com/webhook")
72
+ assert "Invalid Discord webhook URL" in str(exc_info.value)
73
+
74
+ def test_validate_strips_whitespace(self):
75
+ """Test that whitespace is stripped from URL."""
76
+ service = DiscordNotificationService()
77
+ # Should not raise - whitespace is stripped
78
+ service._validate_webhook_url(
79
+ " https://discord.com/api/webhooks/123456789/abcdef "
80
+ )
81
+
82
+
83
+ class TestDiscordNotificationServicePostMessage:
84
+ """Test message posting."""
85
+
86
+ @patch("backend.services.discord_service.requests.post")
87
+ def test_post_message_success(self, mock_post):
88
+ """Test successful message posting."""
89
+ mock_response = MagicMock()
90
+ mock_response.raise_for_status = MagicMock()
91
+ mock_post.return_value = mock_response
92
+
93
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
94
+ service = DiscordNotificationService(webhook_url=url)
95
+
96
+ result = service.post_message("Test message")
97
+
98
+ assert result is True
99
+ mock_post.assert_called_once_with(
100
+ url,
101
+ json={"content": "Test message"},
102
+ timeout=30
103
+ )
104
+
105
+ @patch("backend.services.discord_service.requests.post")
106
+ def test_post_message_with_custom_webhook(self, mock_post):
107
+ """Test message posting with custom webhook URL."""
108
+ mock_response = MagicMock()
109
+ mock_response.raise_for_status = MagicMock()
110
+ mock_post.return_value = mock_response
111
+
112
+ service = DiscordNotificationService()
113
+ custom_url = "https://discord.com/api/webhooks/987654321/fedcba"
114
+
115
+ result = service.post_message("Test message", webhook_url=custom_url)
116
+
117
+ assert result is True
118
+ mock_post.assert_called_once_with(
119
+ custom_url,
120
+ json={"content": "Test message"},
121
+ timeout=30
122
+ )
123
+
124
+ def test_post_message_no_webhook_raises(self):
125
+ """Test that posting without webhook URL raises ValueError."""
126
+ service = DiscordNotificationService()
127
+
128
+ with pytest.raises(ValueError) as exc_info:
129
+ service.post_message("Test message")
130
+ assert "No Discord webhook URL provided" in str(exc_info.value)
131
+
132
+ def test_post_message_dry_run(self):
133
+ """Test message posting in dry run mode."""
134
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
135
+ service = DiscordNotificationService(webhook_url=url, dry_run=True)
136
+
137
+ result = service.post_message("Test message")
138
+
139
+ assert result is True
140
+ # In dry run mode, no actual request should be made
141
+
142
+ @patch("backend.services.discord_service.requests.post")
143
+ def test_post_message_http_error(self, mock_post):
144
+ """Test handling of HTTP errors."""
145
+ import requests as req
146
+ mock_response = MagicMock()
147
+ mock_response.raise_for_status.side_effect = req.HTTPError("404 Not Found")
148
+ mock_post.return_value = mock_response
149
+
150
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
151
+ service = DiscordNotificationService(webhook_url=url)
152
+
153
+ with pytest.raises(req.HTTPError):
154
+ service.post_message("Test message")
155
+
156
+
157
+ class TestDiscordNotificationServiceVideoNotification:
158
+ """Test video notification posting."""
159
+
160
+ @patch("backend.services.discord_service.requests.post")
161
+ def test_post_video_notification_success(self, mock_post):
162
+ """Test successful video notification."""
163
+ mock_response = MagicMock()
164
+ mock_response.raise_for_status = MagicMock()
165
+ mock_post.return_value = mock_response
166
+
167
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
168
+ service = DiscordNotificationService(webhook_url=url)
169
+
170
+ result = service.post_video_notification(
171
+ "https://www.youtube.com/watch?v=abc123"
172
+ )
173
+
174
+ assert result is True
175
+ mock_post.assert_called_once()
176
+ call_args = mock_post.call_args
177
+ assert "New upload:" in call_args[1]["json"]["content"]
178
+ assert "abc123" in call_args[1]["json"]["content"]
179
+
180
+ def test_post_video_notification_no_youtube_url(self):
181
+ """Test video notification with empty YouTube URL."""
182
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
183
+ service = DiscordNotificationService(webhook_url=url)
184
+
185
+ result = service.post_video_notification("")
186
+
187
+ assert result is False
188
+
189
+ def test_post_video_notification_none_youtube_url(self):
190
+ """Test video notification with None YouTube URL."""
191
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
192
+ service = DiscordNotificationService(webhook_url=url)
193
+
194
+ result = service.post_video_notification(None)
195
+
196
+ assert result is False
197
+
198
+ def test_post_video_notification_dry_run(self):
199
+ """Test video notification in dry run mode."""
200
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
201
+ service = DiscordNotificationService(webhook_url=url, dry_run=True)
202
+
203
+ result = service.post_video_notification(
204
+ "https://www.youtube.com/watch?v=abc123"
205
+ )
206
+
207
+ assert result is True
208
+
209
+
210
+ class TestGetDiscordNotificationService:
211
+ """Test factory function."""
212
+
213
+ def test_get_service_creates_instance(self):
214
+ """Test that factory function creates a new instance."""
215
+ import backend.services.discord_service as module
216
+ module._discord_notification_service = None
217
+
218
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
219
+ service = get_discord_notification_service(webhook_url=url)
220
+
221
+ assert service is not None
222
+ assert isinstance(service, DiscordNotificationService)
223
+ assert service.webhook_url == url
224
+
225
+ def test_get_service_without_webhook(self):
226
+ """Test factory function without webhook URL."""
227
+ import backend.services.discord_service as module
228
+ module._discord_notification_service = None
229
+
230
+ service = get_discord_notification_service()
231
+
232
+ assert service is not None
233
+ assert service.webhook_url is None
234
+ assert service.is_enabled() is False
235
+
236
+ def test_get_service_with_dry_run(self):
237
+ """Test factory function with dry run mode."""
238
+ import backend.services.discord_service as module
239
+ module._discord_notification_service = None
240
+
241
+ url = "https://discord.com/api/webhooks/123456789/abcdef"
242
+ service = get_discord_notification_service(webhook_url=url, dry_run=True)
243
+
244
+ assert service.dry_run is True