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,261 @@
1
+ """
2
+ Tests for style file upload and processing.
3
+
4
+ Tests the full flow:
5
+ 1. CLI parses style_params.json and extracts file references
6
+ 2. Upload endpoint accepts style files
7
+ 3. Style helper downloads and parses style config
8
+ 4. Workers use style config for video generation
9
+ """
10
+ import pytest
11
+ import json
12
+ import tempfile
13
+ import os
14
+ from pathlib import Path
15
+ from unittest.mock import Mock, MagicMock, patch, AsyncMock
16
+ from io import BytesIO
17
+
18
+ # Test the remote CLI's style parsing
19
+ # Skip this class if flacfetch is not properly installed (remote_cli depends on it)
20
+ try:
21
+ from karaoke_gen.utils.remote_cli import RemoteKaraokeClient, Config
22
+ _remote_cli_available = True
23
+ except ImportError:
24
+ _remote_cli_available = False
25
+
26
+
27
+ @pytest.mark.skipif(not _remote_cli_available, reason="flacfetch not properly installed")
28
+ class TestRemoteCLIStyleParsing:
29
+ """Test that remote CLI correctly parses style_params.json."""
30
+
31
+ def test_parse_style_params_extracts_file_paths(self):
32
+ """Test that _parse_style_params extracts all file references."""
33
+
34
+ # Create a mock style_params.json
35
+ with tempfile.TemporaryDirectory() as temp_dir:
36
+ # Create test files
37
+ font_path = os.path.join(temp_dir, "font.ttf")
38
+ intro_bg_path = os.path.join(temp_dir, "intro_bg.png")
39
+ karaoke_bg_path = os.path.join(temp_dir, "karaoke_bg.png")
40
+
41
+ Path(font_path).touch()
42
+ Path(intro_bg_path).touch()
43
+ Path(karaoke_bg_path).touch()
44
+
45
+ # Create style_params.json
46
+ style_params = {
47
+ "intro": {
48
+ "background_image": intro_bg_path,
49
+ "font": font_path,
50
+ },
51
+ "karaoke": {
52
+ "background_image": karaoke_bg_path,
53
+ "font_path": font_path,
54
+ },
55
+ "end": {
56
+ "background_image": karaoke_bg_path,
57
+ "font": font_path,
58
+ },
59
+ "cdg": {
60
+ "font_path": font_path,
61
+ }
62
+ }
63
+
64
+ style_json_path = os.path.join(temp_dir, "style_params.json")
65
+ with open(style_json_path, 'w') as f:
66
+ json.dump(style_params, f)
67
+
68
+ # Create client and parse
69
+ config = Config(
70
+ service_url="http://test",
71
+ review_ui_url="http://test",
72
+ poll_interval=5,
73
+ output_dir=temp_dir
74
+ )
75
+ logger = Mock()
76
+ client = RemoteKaraokeClient(config, logger)
77
+
78
+ assets = client._parse_style_params(style_json_path)
79
+
80
+ # Should extract unique files
81
+ assert 'style_font' in assets
82
+ assert 'style_intro_background' in assets
83
+ assert 'style_karaoke_background' in assets
84
+ assert assets['style_font'] == font_path
85
+ assert assets['style_intro_background'] == intro_bg_path
86
+ assert assets['style_karaoke_background'] == karaoke_bg_path
87
+
88
+ def test_parse_style_params_handles_missing_files(self):
89
+ """Test that _parse_style_params ignores non-existent files."""
90
+
91
+ with tempfile.TemporaryDirectory() as temp_dir:
92
+ style_params = {
93
+ "intro": {
94
+ "background_image": "/nonexistent/path.png",
95
+ "font": "/nonexistent/font.ttf",
96
+ },
97
+ }
98
+
99
+ style_json_path = os.path.join(temp_dir, "style_params.json")
100
+ with open(style_json_path, 'w') as f:
101
+ json.dump(style_params, f)
102
+
103
+ config = Config(
104
+ service_url="http://test",
105
+ review_ui_url="http://test",
106
+ poll_interval=5,
107
+ output_dir=temp_dir
108
+ )
109
+ logger = Mock()
110
+ client = RemoteKaraokeClient(config, logger)
111
+
112
+ assets = client._parse_style_params(style_json_path)
113
+
114
+ # Should return empty dict for non-existent files
115
+ assert len(assets) == 0
116
+
117
+
118
+ class TestStyleHelper:
119
+ """Test the backend style helper module."""
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_style_config_loads_defaults_when_no_custom_styles(self):
123
+ """Test that StyleConfig returns defaults when no custom styles."""
124
+ pytest.importorskip("google.cloud.storage", reason="GCP libraries not available")
125
+ from backend.workers.style_helper import StyleConfig, DEFAULT_INTRO_FORMAT
126
+
127
+ # Mock job with no style assets and no style_params_gcs_path
128
+ job = Mock()
129
+ job.job_id = "test-123"
130
+ job.style_assets = {}
131
+ job.style_params_gcs_path = None # Explicitly set to None
132
+
133
+ storage = Mock()
134
+
135
+ with tempfile.TemporaryDirectory() as temp_dir:
136
+ config = StyleConfig(job, storage, temp_dir)
137
+ await config.load()
138
+
139
+ assert not config.has_custom_styles()
140
+ intro_format = config.get_intro_format()
141
+ assert intro_format == DEFAULT_INTRO_FORMAT
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_style_config_loads_custom_styles(self):
145
+ """Test that StyleConfig loads and parses custom styles."""
146
+ pytest.importorskip("google.cloud.storage", reason="GCP libraries not available")
147
+ from backend.workers.style_helper import StyleConfig
148
+
149
+ with tempfile.TemporaryDirectory() as temp_dir:
150
+ # Create a SEPARATE source directory for the source style file
151
+ # (StyleConfig will use temp_dir/style/ for downloads, so we use source/)
152
+ source_dir = os.path.join(temp_dir, "source")
153
+ os.makedirs(source_dir, exist_ok=True)
154
+
155
+ # Create a mock style_params.json in the source directory
156
+ style_params = {
157
+ "intro": {
158
+ "background_color": "#FF0000",
159
+ "title_color": "#00FF00",
160
+ },
161
+ "cdg": {
162
+ "background_color": "#0000FF",
163
+ }
164
+ }
165
+ style_json_path = os.path.join(source_dir, "style_params.json")
166
+ with open(style_json_path, 'w') as f:
167
+ json.dump(style_params, f)
168
+
169
+ # Create a work directory for StyleConfig (separate from source)
170
+ work_dir = os.path.join(temp_dir, "work")
171
+ os.makedirs(work_dir, exist_ok=True)
172
+
173
+ # Mock job with style assets
174
+ job = Mock()
175
+ job.job_id = "test-123"
176
+ job.style_assets = {"style_params": "uploads/test/style_params.json"}
177
+
178
+ # Mock storage to "download" the file
179
+ storage = Mock()
180
+ def mock_download(gcs_path, local_path):
181
+ # Copy our test file to the expected location
182
+ import shutil
183
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
184
+ shutil.copy(style_json_path, local_path)
185
+ storage.download_file = mock_download
186
+
187
+ config = StyleConfig(job, storage, work_dir)
188
+ await config.load()
189
+
190
+ assert config.has_custom_styles()
191
+
192
+ intro_format = config.get_intro_format()
193
+ assert intro_format['background_color'] == "#FF0000"
194
+ assert intro_format['title_color'] == "#00FF00"
195
+
196
+ cdg_styles = config.get_cdg_styles()
197
+ assert cdg_styles['background_color'] == "#0000FF"
198
+
199
+
200
+ class TestFileUploadEndpoint:
201
+ """Test the file upload endpoint accepts style files."""
202
+
203
+ def test_upload_endpoint_validates_style_files(self):
204
+ """Test that upload endpoint validates file types."""
205
+ pytest.importorskip("google.cloud.firestore", reason="GCP libraries not available")
206
+ # This would be an integration test with FastAPI TestClient
207
+ # For now, just verify the validation logic exists
208
+ from backend.api.routes.file_upload import (
209
+ ALLOWED_AUDIO_EXTENSIONS,
210
+ ALLOWED_IMAGE_EXTENSIONS,
211
+ ALLOWED_FONT_EXTENSIONS
212
+ )
213
+
214
+ assert '.mp3' in ALLOWED_AUDIO_EXTENSIONS
215
+ assert '.flac' in ALLOWED_AUDIO_EXTENSIONS
216
+ assert '.png' in ALLOWED_IMAGE_EXTENSIONS
217
+ assert '.jpg' in ALLOWED_IMAGE_EXTENSIONS
218
+ assert '.ttf' in ALLOWED_FONT_EXTENSIONS
219
+ assert '.otf' in ALLOWED_FONT_EXTENSIONS
220
+
221
+
222
+ class TestVideoWorkerStyleIntegration:
223
+ """Test that video worker properly uses style config."""
224
+
225
+ def test_video_worker_passes_cdg_styles_to_finalise(self):
226
+ """Test that video worker passes CDG styles to KaraokeFinalise."""
227
+ pytest.importorskip("google.cloud.firestore", reason="GCP libraries not available")
228
+ # This is implicitly tested by the video_worker code structure
229
+ # The key is that cdg_styles is passed to KaraokeFinalise constructor
230
+
231
+ # Verify the import works
232
+ from backend.workers.video_worker import generate_video
233
+ assert generate_video is not None
234
+
235
+ def test_video_worker_passes_discord_webhook(self):
236
+ """Test that video worker passes discord webhook to KaraokeFinalise."""
237
+ # The video_worker.py now passes discord_webhook_url from job
238
+ # This is verified by code inspection
239
+ pass
240
+
241
+
242
+ class TestJobModelStyleFields:
243
+ """Test that Job model has all required style fields."""
244
+
245
+ def test_job_model_has_style_fields(self):
246
+ """Test Job model includes style configuration fields."""
247
+ from backend.models.job import Job, JobCreate
248
+
249
+ # Check Job model fields (use model_fields for Pydantic v2)
250
+ job_fields = Job.model_fields if hasattr(Job, 'model_fields') else Job.__fields__
251
+ assert 'style_params_gcs_path' in job_fields
252
+ assert 'style_assets' in job_fields
253
+ assert 'brand_prefix' in job_fields
254
+ assert 'discord_webhook_url' in job_fields
255
+
256
+ # Check JobCreate model fields
257
+ create_fields = JobCreate.model_fields if hasattr(JobCreate, 'model_fields') else JobCreate.__fields__
258
+ assert 'style_params_gcs_path' in create_fields
259
+ assert 'style_assets' in create_fields
260
+ assert 'brand_prefix' in create_fields
261
+ assert 'discord_webhook_url' in create_fields
@@ -0,0 +1,295 @@
1
+ """
2
+ Unit tests for template service.
3
+ """
4
+ import pytest
5
+ from unittest.mock import Mock, patch, MagicMock
6
+
7
+ from backend.services.template_service import (
8
+ TemplateService,
9
+ get_template_service,
10
+ DEFAULT_JOB_COMPLETION_TEMPLATE,
11
+ DEFAULT_ACTION_NEEDED_LYRICS_TEMPLATE,
12
+ DEFAULT_ACTION_NEEDED_INSTRUMENTAL_TEMPLATE,
13
+ )
14
+
15
+
16
+ class TestTemplateRendering:
17
+ """Tests for template rendering functionality."""
18
+
19
+ def test_render_template_basic(self):
20
+ """Test basic template variable replacement."""
21
+ service = TemplateService()
22
+ template = "Hello {name}, your job {job_id} is ready!"
23
+ variables = {"name": "Alice", "job_id": "123"}
24
+
25
+ result = service.render_template(template, variables)
26
+
27
+ assert result == "Hello Alice, your job 123 is ready!"
28
+
29
+ def test_render_template_missing_variables(self):
30
+ """Test that missing variables are replaced with empty strings."""
31
+ service = TemplateService()
32
+ template = "Hello {name}, your {thing} is {status}!"
33
+ variables = {"name": "Bob"}
34
+
35
+ result = service.render_template(template, variables)
36
+
37
+ assert result == "Hello Bob, your is !"
38
+
39
+ def test_render_template_none_values(self):
40
+ """Test that None values are handled correctly."""
41
+ service = TemplateService()
42
+ template = "Hello {name}!"
43
+ variables = {"name": None}
44
+
45
+ result = service.render_template(template, variables)
46
+
47
+ assert result == "Hello !"
48
+
49
+ def test_render_template_removes_feedback_section_when_no_url(self):
50
+ """Test that feedback section is removed when feedback_url is not provided."""
51
+ service = TemplateService()
52
+ template = """Thanks for your order!
53
+
54
+ If you have a moment, I'd really appreciate your feedback (takes 2 minutes):
55
+ {feedback_url}
56
+
57
+ Have a great day!"""
58
+ variables = {"feedback_url": None}
59
+
60
+ result = service.render_template(template, variables)
61
+
62
+ assert "feedback" not in result.lower()
63
+ assert "Thanks for your order!" in result
64
+ assert "Have a great day!" in result
65
+
66
+ def test_render_template_keeps_feedback_section_when_url_provided(self):
67
+ """Test that feedback section is kept when feedback_url is provided."""
68
+ service = TemplateService()
69
+ template = """Thanks!
70
+
71
+ If you have a moment, I'd really appreciate your feedback (takes 2 minutes):
72
+ {feedback_url}
73
+
74
+ Bye!"""
75
+ variables = {"feedback_url": "https://example.com/feedback"}
76
+
77
+ result = service.render_template(template, variables)
78
+
79
+ assert "https://example.com/feedback" in result
80
+ assert "feedback" in result.lower()
81
+
82
+
83
+ class TestJobCompletionTemplate:
84
+ """Tests for job completion template rendering."""
85
+
86
+ def test_render_job_completion_all_fields(self):
87
+ """Test rendering job completion with all fields."""
88
+ service = TemplateService()
89
+
90
+ # Mock _fetch_template_from_gcs to return None (use default)
91
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
92
+ result = service.render_job_completion(
93
+ name="Alice",
94
+ youtube_url="https://youtube.com/watch?v=123",
95
+ dropbox_url="https://dropbox.com/folder/abc",
96
+ artist="Test Artist",
97
+ title="Test Song",
98
+ job_id="job-123",
99
+ feedback_url="https://example.com/feedback",
100
+ )
101
+
102
+ assert "Alice" in result
103
+ assert "https://youtube.com/watch?v=123" in result
104
+ assert "https://dropbox.com/folder/abc" in result
105
+ assert "https://example.com/feedback" in result
106
+
107
+ def test_render_job_completion_defaults(self):
108
+ """Test rendering job completion with default values."""
109
+ service = TemplateService()
110
+
111
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
112
+ result = service.render_job_completion()
113
+
114
+ assert "there" in result # Default name
115
+ assert "[YouTube URL not available]" in result
116
+ assert "[Dropbox URL not available]" in result
117
+
118
+ def test_render_job_completion_no_feedback_url(self):
119
+ """Test rendering job completion without feedback URL removes section."""
120
+ service = TemplateService()
121
+
122
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
123
+ result = service.render_job_completion(
124
+ name="Bob",
125
+ youtube_url="https://youtube.com/123",
126
+ dropbox_url="https://dropbox.com/abc",
127
+ )
128
+
129
+ assert "Bob" in result
130
+ # Feedback section should be removed
131
+ assert "really appreciate your feedback" not in result
132
+
133
+
134
+ class TestActionNeededTemplates:
135
+ """Tests for action-needed template rendering."""
136
+
137
+ def test_render_action_needed_lyrics(self):
138
+ """Test rendering lyrics review reminder."""
139
+ service = TemplateService()
140
+
141
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
142
+ result = service.render_action_needed_lyrics(
143
+ name="Charlie",
144
+ artist="Test Artist",
145
+ title="Test Song",
146
+ review_url="https://example.com/review",
147
+ )
148
+
149
+ assert "Charlie" in result
150
+ assert "Test Artist" in result
151
+ assert "Test Song" in result
152
+ assert "https://example.com/review" in result
153
+
154
+ def test_render_action_needed_instrumental(self):
155
+ """Test rendering instrumental selection reminder."""
156
+ service = TemplateService()
157
+
158
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
159
+ result = service.render_action_needed_instrumental(
160
+ name="Diana",
161
+ artist="Test Artist",
162
+ title="Test Song",
163
+ instrumental_url="https://example.com/instrumental",
164
+ )
165
+
166
+ assert "Diana" in result
167
+ assert "Test Artist" in result
168
+ assert "Test Song" in result
169
+ assert "https://example.com/instrumental" in result
170
+
171
+
172
+ class TestGCSFetching:
173
+ """Tests for GCS template fetching."""
174
+
175
+ def test_fetch_template_from_gcs_success(self):
176
+ """Test successful template fetch from GCS."""
177
+ service = TemplateService()
178
+
179
+ # Mock the bucket and blob
180
+ mock_blob = Mock()
181
+ mock_blob.exists.return_value = True
182
+ mock_blob.download_as_text.return_value = "Custom template {name}"
183
+
184
+ mock_bucket = Mock()
185
+ mock_bucket.blob.return_value = mock_blob
186
+
187
+ with patch.object(service, "_bucket", mock_bucket):
188
+ result = service._fetch_template_from_gcs("test.txt")
189
+
190
+ assert result == "Custom template {name}"
191
+ mock_bucket.blob.assert_called_once_with("templates/test.txt")
192
+
193
+ def test_fetch_template_from_gcs_not_found(self):
194
+ """Test template fetch when file doesn't exist in GCS."""
195
+ service = TemplateService()
196
+
197
+ mock_blob = Mock()
198
+ mock_blob.exists.return_value = False
199
+
200
+ mock_bucket = Mock()
201
+ mock_bucket.blob.return_value = mock_blob
202
+
203
+ with patch.object(service, "_bucket", mock_bucket):
204
+ result = service._fetch_template_from_gcs("nonexistent.txt")
205
+
206
+ assert result is None
207
+
208
+ def test_fetch_template_from_gcs_error(self):
209
+ """Test template fetch handles errors gracefully."""
210
+ service = TemplateService()
211
+
212
+ mock_bucket = Mock()
213
+ mock_bucket.blob.side_effect = Exception("GCS error")
214
+
215
+ with patch.object(service, "_bucket", mock_bucket):
216
+ result = service._fetch_template_from_gcs("test.txt")
217
+
218
+ assert result is None
219
+
220
+ def test_get_job_completion_template_fallback(self):
221
+ """Test that default template is used when GCS fetch fails."""
222
+ service = TemplateService()
223
+
224
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
225
+ result = service.get_job_completion_template()
226
+
227
+ assert result == DEFAULT_JOB_COMPLETION_TEMPLATE
228
+
229
+ def test_get_action_needed_lyrics_template_fallback(self):
230
+ """Test that default lyrics template is used when GCS fetch fails."""
231
+ service = TemplateService()
232
+
233
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
234
+ result = service.get_action_needed_lyrics_template()
235
+
236
+ assert result == DEFAULT_ACTION_NEEDED_LYRICS_TEMPLATE
237
+
238
+ def test_get_action_needed_instrumental_template_fallback(self):
239
+ """Test that default instrumental template is used when GCS fetch fails."""
240
+ service = TemplateService()
241
+
242
+ with patch.object(service, "_fetch_template_from_gcs", return_value=None):
243
+ result = service.get_action_needed_instrumental_template()
244
+
245
+ assert result == DEFAULT_ACTION_NEEDED_INSTRUMENTAL_TEMPLATE
246
+
247
+
248
+ class TestTemplateUpload:
249
+ """Tests for template upload functionality."""
250
+
251
+ def test_upload_template_success(self):
252
+ """Test successful template upload."""
253
+ service = TemplateService()
254
+
255
+ mock_blob = Mock()
256
+ mock_bucket = Mock()
257
+ mock_bucket.blob.return_value = mock_blob
258
+
259
+ with patch.object(service, "_bucket", mock_bucket):
260
+ result = service.upload_template("test.txt", "Test content")
261
+
262
+ assert result is True
263
+ mock_bucket.blob.assert_called_once_with("templates/test.txt")
264
+ mock_blob.upload_from_string.assert_called_once_with(
265
+ "Test content", content_type="text/plain"
266
+ )
267
+
268
+ def test_upload_template_error(self):
269
+ """Test template upload handles errors."""
270
+ service = TemplateService()
271
+
272
+ mock_blob = Mock()
273
+ mock_blob.upload_from_string.side_effect = Exception("Upload failed")
274
+ mock_bucket = Mock()
275
+ mock_bucket.blob.return_value = mock_blob
276
+
277
+ with patch.object(service, "_bucket", mock_bucket):
278
+ result = service.upload_template("test.txt", "Test content")
279
+
280
+ assert result is False
281
+
282
+
283
+ class TestGlobalInstance:
284
+ """Tests for global instance management."""
285
+
286
+ def test_get_template_service_returns_same_instance(self):
287
+ """Test that get_template_service returns singleton."""
288
+ # Reset global
289
+ import backend.services.template_service as ts
290
+ ts._template_service = None
291
+
292
+ service1 = get_template_service()
293
+ service2 = get_template_service()
294
+
295
+ assert service1 is service2