karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +502 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +109 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +356 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +283 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/utils/test_data.py +27 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +535 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.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
|