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,178 @@
1
+ """
2
+ Extended worker tests focusing on helper functions and utilities.
3
+
4
+ These tests increase coverage without requiring complex external mocking.
5
+ """
6
+ import pytest
7
+ import os
8
+ import json
9
+ import tempfile
10
+ from datetime import datetime, UTC
11
+ from unittest.mock import MagicMock, AsyncMock, patch
12
+
13
+ from backend.models.job import Job, JobStatus
14
+
15
+
16
+ class TestAudioWorkerHelpers:
17
+ """Tests for audio_worker.py helper functions and utilities."""
18
+
19
+ def test_audio_worker_module_structure(self):
20
+ """Test audio worker module has expected structure."""
21
+ from backend.workers import audio_worker
22
+ assert hasattr(audio_worker, 'process_audio_separation')
23
+ assert hasattr(audio_worker, 'download_audio')
24
+ assert hasattr(audio_worker, 'upload_separation_results')
25
+
26
+ def test_audio_worker_logger_configured(self):
27
+ """Test audio worker has logger configured."""
28
+ from backend.workers.audio_worker import logger
29
+ assert logger is not None
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_upload_separation_results_with_empty_result(self):
33
+ """Test upload with empty separation result."""
34
+ from backend.workers.audio_worker import upload_separation_results
35
+
36
+ mock_storage = MagicMock()
37
+ mock_job_manager = MagicMock()
38
+ mock_job_manager.get_job.return_value = Job(
39
+ job_id="test",
40
+ status=JobStatus.SEPARATING_STAGE2,
41
+ created_at=datetime.now(UTC),
42
+ updated_at=datetime.now(UTC)
43
+ )
44
+
45
+ # Empty result should not crash
46
+ await upload_separation_results(
47
+ "test123",
48
+ {"clean_instrumental": {}, "other_stems": {}, "backing_vocals": {}, "combined_instrumentals": {}},
49
+ mock_storage,
50
+ mock_job_manager
51
+ )
52
+
53
+
54
+ class TestLyricsWorkerHelpers:
55
+ """Tests for lyrics_worker.py helper functions."""
56
+
57
+ def test_lyrics_worker_module_structure(self):
58
+ """Test lyrics worker module has expected structure."""
59
+ from backend.workers import lyrics_worker
60
+ assert hasattr(lyrics_worker, 'process_lyrics_transcription')
61
+ assert hasattr(lyrics_worker, 'upload_lyrics_results')
62
+
63
+ def test_lyrics_worker_logger_configured(self):
64
+ """Test lyrics worker has logger configured."""
65
+ from backend.workers.lyrics_worker import logger
66
+ assert logger is not None
67
+
68
+
69
+ class TestScreensWorkerHelpers:
70
+ """Tests for screens_worker.py helper functions."""
71
+
72
+ def test_screens_worker_module_structure(self):
73
+ """Test screens worker module has expected structure."""
74
+ from backend.workers import screens_worker
75
+ assert hasattr(screens_worker, 'generate_screens')
76
+
77
+ def test_screens_worker_logger_configured(self):
78
+ """Test screens worker has logger configured."""
79
+ from backend.workers.screens_worker import logger
80
+ assert logger is not None
81
+
82
+
83
+ class TestVideoWorkerHelpers:
84
+ """Tests for video_worker.py helper functions."""
85
+
86
+ def test_video_worker_module_structure(self):
87
+ """Test video worker module has expected structure."""
88
+ from backend.workers import video_worker
89
+ assert hasattr(video_worker, 'generate_video')
90
+
91
+ def test_video_worker_logger_configured(self):
92
+ """Test video worker has logger configured."""
93
+ from backend.workers.video_worker import logger
94
+ assert logger is not None
95
+
96
+
97
+ class TestWorkerJobValidation:
98
+ """Tests for job validation in workers."""
99
+
100
+ @pytest.fixture
101
+ def mock_storage(self):
102
+ return MagicMock()
103
+
104
+ @pytest.fixture
105
+ def mock_job_manager(self):
106
+ manager = MagicMock()
107
+ manager.mark_job_failed = MagicMock()
108
+ return manager
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_audio_worker_handles_missing_job(self, mock_storage, mock_job_manager):
112
+ """Test audio worker handles missing job gracefully."""
113
+ mock_job_manager.get_job.return_value = None
114
+
115
+ with patch('backend.workers.audio_worker.JobManager', return_value=mock_job_manager), \
116
+ patch('backend.workers.audio_worker.StorageService', return_value=mock_storage):
117
+ from backend.workers.audio_worker import process_audio_separation
118
+
119
+ await process_audio_separation("nonexistent")
120
+ # Should handle gracefully, potentially marking as failed
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_lyrics_worker_handles_missing_job(self, mock_storage, mock_job_manager):
124
+ """Test lyrics worker handles missing job gracefully."""
125
+ mock_job_manager.get_job.return_value = None
126
+
127
+ with patch('backend.workers.lyrics_worker.JobManager', return_value=mock_job_manager), \
128
+ patch('backend.workers.lyrics_worker.StorageService', return_value=mock_storage):
129
+ from backend.workers.lyrics_worker import process_lyrics_transcription
130
+
131
+ await process_lyrics_transcription("nonexistent")
132
+
133
+
134
+ class TestWorkerFileOperations:
135
+ """Tests for worker file operations."""
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_download_audio_from_gcs_path(self):
139
+ """Test downloading audio from GCS path."""
140
+ from backend.workers.audio_worker import download_audio
141
+
142
+ mock_storage = MagicMock()
143
+ mock_storage.download_file.return_value = "/tmp/test.flac"
144
+
145
+ mock_job = Job(
146
+ job_id="test123",
147
+ status=JobStatus.DOWNLOADING,
148
+ created_at=datetime.now(UTC),
149
+ updated_at=datetime.now(UTC),
150
+ input_media_gcs_path="uploads/test123/song.flac"
151
+ )
152
+
153
+ with tempfile.TemporaryDirectory() as temp_dir:
154
+ result = await download_audio("test123", temp_dir, mock_storage, mock_job)
155
+ mock_storage.download_file.assert_called()
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_lyrics_download_audio(self):
159
+ """Test lyrics worker downloads audio."""
160
+ from backend.workers.lyrics_worker import download_audio
161
+
162
+ mock_storage = MagicMock()
163
+ mock_storage.download_file.return_value = "/tmp/test.flac"
164
+
165
+ mock_job_manager = MagicMock()
166
+ mock_job = Job(
167
+ job_id="test123",
168
+ status=JobStatus.TRANSCRIBING,
169
+ created_at=datetime.now(UTC),
170
+ updated_at=datetime.now(UTC),
171
+ input_media_gcs_path="uploads/test123/song.flac"
172
+ )
173
+ mock_job_manager.get_job.return_value = mock_job
174
+
175
+ with tempfile.TemporaryDirectory() as temp_dir:
176
+ result = await download_audio("test123", temp_dir, mock_storage, mock_job, mock_job_manager)
177
+ mock_storage.download_file.assert_called()
178
+
@@ -0,0 +1,247 @@
1
+ """
2
+ Tests for youtube_service.py - YouTube credential management.
3
+
4
+ These tests mock Secret Manager to verify:
5
+ - Credential loading and parsing
6
+ - Validation of required fields
7
+ - Error handling for missing/invalid credentials
8
+ """
9
+ import json
10
+ import pytest
11
+ from unittest.mock import Mock, patch
12
+
13
+
14
+ class TestYouTubeServiceInit:
15
+ """Test YouTubeService initialization."""
16
+
17
+ @patch("backend.services.youtube_service.get_settings")
18
+ def test_init_creates_service(self, mock_get_settings):
19
+ """Test initialization creates service with settings."""
20
+ from backend.services.youtube_service import YouTubeService
21
+
22
+ mock_settings = Mock()
23
+ mock_get_settings.return_value = mock_settings
24
+
25
+ service = YouTubeService()
26
+
27
+ assert service.settings == mock_settings
28
+ assert service._credentials is None
29
+ assert service._loaded is False
30
+
31
+
32
+ class TestLoadCredentials:
33
+ """Test load_credentials method."""
34
+
35
+ @patch("backend.services.youtube_service.get_settings")
36
+ def test_load_credentials_success(self, mock_get_settings):
37
+ """Test successful credential loading from Secret Manager."""
38
+ from backend.services.youtube_service import YouTubeService
39
+
40
+ mock_settings = Mock()
41
+ mock_settings.get_secret.return_value = json.dumps({
42
+ "token": "access-token-123",
43
+ "refresh_token": "refresh-token-456",
44
+ "token_uri": "https://oauth2.googleapis.com/token",
45
+ "client_id": "client-id.apps.googleusercontent.com",
46
+ "client_secret": "client-secret-789",
47
+ "scopes": ["https://www.googleapis.com/auth/youtube.upload"],
48
+ })
49
+ mock_get_settings.return_value = mock_settings
50
+
51
+ service = YouTubeService()
52
+ result = service.load_credentials()
53
+
54
+ assert result is True
55
+ assert service._credentials is not None
56
+ assert service._credentials["refresh_token"] == "refresh-token-456"
57
+ mock_settings.get_secret.assert_called_once_with("youtube-oauth-credentials")
58
+
59
+ @patch("backend.services.youtube_service.get_settings")
60
+ def test_load_credentials_not_found(self, mock_get_settings):
61
+ """Test handling when credentials are not in Secret Manager."""
62
+ from backend.services.youtube_service import YouTubeService
63
+
64
+ mock_settings = Mock()
65
+ mock_settings.get_secret.return_value = None
66
+ mock_get_settings.return_value = mock_settings
67
+
68
+ service = YouTubeService()
69
+ result = service.load_credentials()
70
+
71
+ assert result is False
72
+ assert service._credentials is None
73
+ assert service._loaded is True
74
+
75
+ @patch("backend.services.youtube_service.get_settings")
76
+ def test_load_credentials_missing_required_fields(self, mock_get_settings):
77
+ """Test handling when credentials are missing required fields."""
78
+ from backend.services.youtube_service import YouTubeService
79
+
80
+ mock_settings = Mock()
81
+ mock_settings.get_secret.return_value = json.dumps({
82
+ "token": "access-token",
83
+ # Missing: refresh_token, token_uri, client_id, client_secret
84
+ })
85
+ mock_get_settings.return_value = mock_settings
86
+
87
+ service = YouTubeService()
88
+ result = service.load_credentials()
89
+
90
+ assert result is False
91
+ assert service._credentials is None
92
+
93
+ @patch("backend.services.youtube_service.get_settings")
94
+ def test_load_credentials_invalid_json(self, mock_get_settings):
95
+ """Test handling of invalid JSON in credentials."""
96
+ from backend.services.youtube_service import YouTubeService
97
+
98
+ mock_settings = Mock()
99
+ mock_settings.get_secret.return_value = "not valid json {"
100
+ mock_get_settings.return_value = mock_settings
101
+
102
+ service = YouTubeService()
103
+ result = service.load_credentials()
104
+
105
+ assert result is False
106
+ assert service._credentials is None
107
+
108
+ @patch("backend.services.youtube_service.get_settings")
109
+ def test_load_credentials_exception(self, mock_get_settings):
110
+ """Test handling of exceptions during credential loading."""
111
+ from backend.services.youtube_service import YouTubeService
112
+
113
+ mock_settings = Mock()
114
+ mock_settings.get_secret.side_effect = Exception("Secret Manager error")
115
+ mock_get_settings.return_value = mock_settings
116
+
117
+ service = YouTubeService()
118
+ result = service.load_credentials()
119
+
120
+ assert result is False
121
+ assert service._loaded is True
122
+
123
+ @patch("backend.services.youtube_service.get_settings")
124
+ def test_load_credentials_cached(self, mock_get_settings):
125
+ """Test that credentials are cached after first load."""
126
+ from backend.services.youtube_service import YouTubeService
127
+
128
+ mock_settings = Mock()
129
+ mock_settings.get_secret.return_value = json.dumps({
130
+ "refresh_token": "token",
131
+ "token_uri": "uri",
132
+ "client_id": "id",
133
+ "client_secret": "secret",
134
+ })
135
+ mock_get_settings.return_value = mock_settings
136
+
137
+ service = YouTubeService()
138
+
139
+ # First call
140
+ result1 = service.load_credentials()
141
+ # Second call
142
+ result2 = service.load_credentials()
143
+
144
+ assert result1 is True
145
+ assert result2 is True
146
+ # Secret Manager should only be called once
147
+ assert mock_settings.get_secret.call_count == 1
148
+
149
+
150
+ class TestGetCredentialsDict:
151
+ """Test get_credentials_dict method."""
152
+
153
+ @patch("backend.services.youtube_service.get_settings")
154
+ def test_get_credentials_dict_loads_if_needed(self, mock_get_settings):
155
+ """Test get_credentials_dict loads credentials if not already loaded."""
156
+ from backend.services.youtube_service import YouTubeService
157
+
158
+ mock_settings = Mock()
159
+ mock_settings.get_secret.return_value = json.dumps({
160
+ "refresh_token": "token",
161
+ "token_uri": "uri",
162
+ "client_id": "id",
163
+ "client_secret": "secret",
164
+ })
165
+ mock_get_settings.return_value = mock_settings
166
+
167
+ service = YouTubeService()
168
+
169
+ # Don't explicitly call load_credentials
170
+ result = service.get_credentials_dict()
171
+
172
+ assert result is not None
173
+ assert result["refresh_token"] == "token"
174
+
175
+ @patch("backend.services.youtube_service.get_settings")
176
+ def test_get_credentials_dict_returns_none_if_not_configured(
177
+ self, mock_get_settings
178
+ ):
179
+ """Test get_credentials_dict returns None when not configured."""
180
+ from backend.services.youtube_service import YouTubeService
181
+
182
+ mock_settings = Mock()
183
+ mock_settings.get_secret.return_value = None
184
+ mock_get_settings.return_value = mock_settings
185
+
186
+ service = YouTubeService()
187
+ result = service.get_credentials_dict()
188
+
189
+ assert result is None
190
+
191
+
192
+ class TestIsConfigured:
193
+ """Test is_configured property."""
194
+
195
+ @patch("backend.services.youtube_service.get_settings")
196
+ def test_is_configured_true(self, mock_get_settings):
197
+ """Test is_configured returns True when credentials exist."""
198
+ from backend.services.youtube_service import YouTubeService
199
+
200
+ mock_settings = Mock()
201
+ mock_settings.get_secret.return_value = json.dumps({
202
+ "refresh_token": "token",
203
+ "token_uri": "uri",
204
+ "client_id": "id",
205
+ "client_secret": "secret",
206
+ })
207
+ mock_get_settings.return_value = mock_settings
208
+
209
+ service = YouTubeService()
210
+
211
+ assert service.is_configured is True
212
+
213
+ @patch("backend.services.youtube_service.get_settings")
214
+ def test_is_configured_false(self, mock_get_settings):
215
+ """Test is_configured returns False when credentials missing."""
216
+ from backend.services.youtube_service import YouTubeService
217
+
218
+ mock_settings = Mock()
219
+ mock_settings.get_secret.return_value = None
220
+ mock_get_settings.return_value = mock_settings
221
+
222
+ service = YouTubeService()
223
+
224
+ assert service.is_configured is False
225
+
226
+
227
+ class TestGetYouTubeService:
228
+ """Test get_youtube_service singleton."""
229
+
230
+ @patch("backend.services.youtube_service.get_settings")
231
+ def test_get_youtube_service_singleton(self, mock_get_settings):
232
+ """Test get_youtube_service returns singleton instance."""
233
+ from backend.services.youtube_service import get_youtube_service
234
+ import backend.services.youtube_service as youtube_module
235
+
236
+ # Reset singleton
237
+ youtube_module._youtube_service = None
238
+
239
+ mock_settings = Mock()
240
+ mock_settings.get_secret.return_value = None
241
+ mock_get_settings.return_value = mock_settings
242
+
243
+ service1 = get_youtube_service()
244
+ service2 = get_youtube_service()
245
+
246
+ assert service1 is service2
247
+