karaoke-gen 0.96.0__py3-none-any.whl → 0.101.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.
- backend/api/routes/admin.py +696 -92
- backend/api/routes/audio_search.py +29 -8
- backend/api/routes/file_upload.py +99 -22
- backend/api/routes/health.py +65 -0
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +28 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +472 -51
- backend/main.py +31 -2
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/encoding_service.py +128 -31
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +44 -2
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +133 -11
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +171 -9
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for TenantService.
|
|
3
|
+
|
|
4
|
+
Tests config loading from GCS, caching behavior, subdomain resolution,
|
|
5
|
+
and email domain validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
from backend.services.tenant_service import TenantService, get_tenant_service
|
|
13
|
+
from backend.models.tenant import TenantConfig, TenantPublicConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Sample tenant config data that would come from GCS
|
|
17
|
+
SAMPLE_VOCALSTAR_CONFIG = {
|
|
18
|
+
"id": "vocalstar",
|
|
19
|
+
"name": "Vocal Star",
|
|
20
|
+
"subdomain": "vocalstar.nomadkaraoke.com",
|
|
21
|
+
"is_active": True,
|
|
22
|
+
"branding": {
|
|
23
|
+
"logo_url": "https://example.com/logo.png",
|
|
24
|
+
"logo_height": 50,
|
|
25
|
+
"primary_color": "#ffff00",
|
|
26
|
+
"secondary_color": "#006CF9",
|
|
27
|
+
"site_title": "Vocal Star Karaoke Generator",
|
|
28
|
+
},
|
|
29
|
+
"features": {
|
|
30
|
+
"audio_search": False,
|
|
31
|
+
"file_upload": True,
|
|
32
|
+
"youtube_url": False,
|
|
33
|
+
"youtube_upload": False,
|
|
34
|
+
"dropbox_upload": False,
|
|
35
|
+
"gdrive_upload": False,
|
|
36
|
+
"theme_selection": False,
|
|
37
|
+
},
|
|
38
|
+
"defaults": {
|
|
39
|
+
"theme_id": "vocalstar",
|
|
40
|
+
"locked_theme": "vocalstar",
|
|
41
|
+
"distribution_mode": "download_only",
|
|
42
|
+
},
|
|
43
|
+
"auth": {
|
|
44
|
+
"allowed_email_domains": ["vocal-star.com", "vocalstarmusic.com"],
|
|
45
|
+
"require_email_domain": True,
|
|
46
|
+
"sender_email": "vocalstar@nomadkaraoke.com",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
SAMPLE_INACTIVE_CONFIG = {
|
|
51
|
+
"id": "inactive",
|
|
52
|
+
"name": "Inactive Tenant",
|
|
53
|
+
"subdomain": "inactive.nomadkaraoke.com",
|
|
54
|
+
"is_active": False,
|
|
55
|
+
"branding": {},
|
|
56
|
+
"features": {},
|
|
57
|
+
"defaults": {},
|
|
58
|
+
"auth": {},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestTenantService:
|
|
63
|
+
"""Tests for TenantService class."""
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def mock_storage(self):
|
|
67
|
+
"""Create a mock StorageService."""
|
|
68
|
+
storage = MagicMock()
|
|
69
|
+
storage.file_exists.return_value = False
|
|
70
|
+
storage.download_json.return_value = {}
|
|
71
|
+
storage.generate_signed_url.return_value = "https://signed-url.example.com"
|
|
72
|
+
return storage
|
|
73
|
+
|
|
74
|
+
@pytest.fixture
|
|
75
|
+
def tenant_service(self, mock_storage):
|
|
76
|
+
"""Create a TenantService with mock storage."""
|
|
77
|
+
return TenantService(storage=mock_storage)
|
|
78
|
+
|
|
79
|
+
# Tests for get_tenant_config()
|
|
80
|
+
def test_get_tenant_config_success(self, tenant_service, mock_storage):
|
|
81
|
+
"""Test successful config loading from GCS."""
|
|
82
|
+
mock_storage.file_exists.return_value = True
|
|
83
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
84
|
+
|
|
85
|
+
config = tenant_service.get_tenant_config("vocalstar")
|
|
86
|
+
|
|
87
|
+
assert config is not None
|
|
88
|
+
assert config.id == "vocalstar"
|
|
89
|
+
assert config.name == "Vocal Star"
|
|
90
|
+
assert config.features.audio_search is False
|
|
91
|
+
mock_storage.download_json.assert_called_once_with("tenants/vocalstar/config.json")
|
|
92
|
+
|
|
93
|
+
def test_get_tenant_config_not_found(self, tenant_service, mock_storage):
|
|
94
|
+
"""Test returns None when tenant config doesn't exist."""
|
|
95
|
+
mock_storage.file_exists.return_value = False
|
|
96
|
+
|
|
97
|
+
config = tenant_service.get_tenant_config("nonexistent")
|
|
98
|
+
|
|
99
|
+
assert config is None
|
|
100
|
+
mock_storage.download_json.assert_not_called()
|
|
101
|
+
|
|
102
|
+
def test_get_tenant_config_gcs_error(self, tenant_service, mock_storage):
|
|
103
|
+
"""Test handles GCS errors gracefully."""
|
|
104
|
+
mock_storage.file_exists.return_value = True
|
|
105
|
+
mock_storage.download_json.side_effect = Exception("GCS error")
|
|
106
|
+
|
|
107
|
+
config = tenant_service.get_tenant_config("vocalstar")
|
|
108
|
+
|
|
109
|
+
assert config is None
|
|
110
|
+
|
|
111
|
+
# Tests for caching behavior
|
|
112
|
+
def test_config_is_cached(self, tenant_service, mock_storage):
|
|
113
|
+
"""Test that configs are cached after first load."""
|
|
114
|
+
mock_storage.file_exists.return_value = True
|
|
115
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
116
|
+
|
|
117
|
+
# First call loads from GCS
|
|
118
|
+
config1 = tenant_service.get_tenant_config("vocalstar")
|
|
119
|
+
# Second call should use cache
|
|
120
|
+
config2 = tenant_service.get_tenant_config("vocalstar")
|
|
121
|
+
|
|
122
|
+
assert config1 is config2
|
|
123
|
+
# download_json should only be called once
|
|
124
|
+
assert mock_storage.download_json.call_count == 1
|
|
125
|
+
|
|
126
|
+
def test_force_refresh_bypasses_cache(self, tenant_service, mock_storage):
|
|
127
|
+
"""Test that force_refresh reloads from GCS."""
|
|
128
|
+
mock_storage.file_exists.return_value = True
|
|
129
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
130
|
+
|
|
131
|
+
# First call
|
|
132
|
+
tenant_service.get_tenant_config("vocalstar")
|
|
133
|
+
# Force refresh
|
|
134
|
+
tenant_service.get_tenant_config("vocalstar", force_refresh=True)
|
|
135
|
+
|
|
136
|
+
# download_json should be called twice
|
|
137
|
+
assert mock_storage.download_json.call_count == 2
|
|
138
|
+
|
|
139
|
+
def test_cache_expires_after_ttl(self, tenant_service, mock_storage):
|
|
140
|
+
"""Test that cache expires after TTL."""
|
|
141
|
+
mock_storage.file_exists.return_value = True
|
|
142
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
143
|
+
|
|
144
|
+
# Load config
|
|
145
|
+
tenant_service.get_tenant_config("vocalstar")
|
|
146
|
+
|
|
147
|
+
# Simulate cache expiration by manipulating cache time
|
|
148
|
+
tenant_service._cache_times["vocalstar"] = datetime.now() - timedelta(seconds=400)
|
|
149
|
+
|
|
150
|
+
# Next call should reload
|
|
151
|
+
tenant_service.get_tenant_config("vocalstar")
|
|
152
|
+
|
|
153
|
+
assert mock_storage.download_json.call_count == 2
|
|
154
|
+
|
|
155
|
+
# Tests for get_tenant_by_subdomain()
|
|
156
|
+
def test_get_tenant_by_subdomain_cached(self, tenant_service, mock_storage):
|
|
157
|
+
"""Test subdomain lookup uses cache."""
|
|
158
|
+
mock_storage.file_exists.return_value = True
|
|
159
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
160
|
+
|
|
161
|
+
# First load by ID to populate cache
|
|
162
|
+
tenant_service.get_tenant_config("vocalstar")
|
|
163
|
+
|
|
164
|
+
# Now lookup by subdomain should use cached mapping
|
|
165
|
+
config = tenant_service.get_tenant_by_subdomain("vocalstar.nomadkaraoke.com")
|
|
166
|
+
|
|
167
|
+
assert config is not None
|
|
168
|
+
assert config.id == "vocalstar"
|
|
169
|
+
|
|
170
|
+
def test_get_tenant_by_subdomain_fallback_resolution(self, tenant_service, mock_storage):
|
|
171
|
+
"""Test subdomain resolution when not in cache."""
|
|
172
|
+
mock_storage.file_exists.return_value = True
|
|
173
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
174
|
+
|
|
175
|
+
# Lookup by subdomain without prior cache
|
|
176
|
+
config = tenant_service.get_tenant_by_subdomain("vocalstar.nomadkaraoke.com")
|
|
177
|
+
|
|
178
|
+
assert config is not None
|
|
179
|
+
assert config.id == "vocalstar"
|
|
180
|
+
|
|
181
|
+
def test_get_tenant_by_subdomain_not_found(self, tenant_service, mock_storage):
|
|
182
|
+
"""Test returns None for unknown subdomain."""
|
|
183
|
+
mock_storage.file_exists.return_value = False
|
|
184
|
+
|
|
185
|
+
config = tenant_service.get_tenant_by_subdomain("unknown.nomadkaraoke.com")
|
|
186
|
+
|
|
187
|
+
assert config is None
|
|
188
|
+
|
|
189
|
+
def test_get_tenant_by_subdomain_case_insensitive(self, tenant_service, mock_storage):
|
|
190
|
+
"""Test subdomain lookup is case insensitive."""
|
|
191
|
+
mock_storage.file_exists.return_value = True
|
|
192
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
193
|
+
|
|
194
|
+
config = tenant_service.get_tenant_by_subdomain("VOCALSTAR.NomadKaraoke.COM")
|
|
195
|
+
|
|
196
|
+
assert config is not None
|
|
197
|
+
assert config.id == "vocalstar"
|
|
198
|
+
|
|
199
|
+
# Tests for _resolve_subdomain_to_tenant_id()
|
|
200
|
+
def test_resolve_subdomain_3_parts(self, tenant_service, mock_storage):
|
|
201
|
+
"""Test resolving tenant.nomadkaraoke.com pattern."""
|
|
202
|
+
mock_storage.file_exists.return_value = True
|
|
203
|
+
|
|
204
|
+
tenant_id = tenant_service._resolve_subdomain_to_tenant_id("vocalstar.nomadkaraoke.com")
|
|
205
|
+
|
|
206
|
+
assert tenant_id == "vocalstar"
|
|
207
|
+
|
|
208
|
+
def test_resolve_subdomain_4_parts(self, tenant_service, mock_storage):
|
|
209
|
+
"""Test resolving tenant.gen.nomadkaraoke.com pattern."""
|
|
210
|
+
mock_storage.file_exists.return_value = True
|
|
211
|
+
|
|
212
|
+
tenant_id = tenant_service._resolve_subdomain_to_tenant_id("vocalstar.gen.nomadkaraoke.com")
|
|
213
|
+
|
|
214
|
+
assert tenant_id == "vocalstar"
|
|
215
|
+
|
|
216
|
+
def test_resolve_subdomain_too_short(self, tenant_service, mock_storage):
|
|
217
|
+
"""Test returns None for subdomains with fewer than 3 parts."""
|
|
218
|
+
tenant_id = tenant_service._resolve_subdomain_to_tenant_id("nomadkaraoke.com")
|
|
219
|
+
|
|
220
|
+
assert tenant_id is None
|
|
221
|
+
|
|
222
|
+
def test_resolve_subdomain_tenant_not_exists(self, tenant_service, mock_storage):
|
|
223
|
+
"""Test returns None when tenant config doesn't exist in GCS."""
|
|
224
|
+
mock_storage.file_exists.return_value = False
|
|
225
|
+
|
|
226
|
+
tenant_id = tenant_service._resolve_subdomain_to_tenant_id("nonexistent.nomadkaraoke.com")
|
|
227
|
+
|
|
228
|
+
assert tenant_id is None
|
|
229
|
+
|
|
230
|
+
# Tests for get_public_config()
|
|
231
|
+
def test_get_public_config_success(self, tenant_service, mock_storage):
|
|
232
|
+
"""Test getting public config."""
|
|
233
|
+
mock_storage.file_exists.return_value = True
|
|
234
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
235
|
+
|
|
236
|
+
public_config = tenant_service.get_public_config("vocalstar")
|
|
237
|
+
|
|
238
|
+
assert public_config is not None
|
|
239
|
+
assert isinstance(public_config, TenantPublicConfig)
|
|
240
|
+
assert public_config.id == "vocalstar"
|
|
241
|
+
|
|
242
|
+
def test_get_public_config_not_found(self, tenant_service, mock_storage):
|
|
243
|
+
"""Test returns None when tenant not found."""
|
|
244
|
+
mock_storage.file_exists.return_value = False
|
|
245
|
+
|
|
246
|
+
public_config = tenant_service.get_public_config("nonexistent")
|
|
247
|
+
|
|
248
|
+
assert public_config is None
|
|
249
|
+
|
|
250
|
+
def test_get_public_config_by_subdomain(self, tenant_service, mock_storage):
|
|
251
|
+
"""Test getting public config by subdomain."""
|
|
252
|
+
mock_storage.file_exists.return_value = True
|
|
253
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
254
|
+
|
|
255
|
+
public_config = tenant_service.get_public_config_by_subdomain("vocalstar.nomadkaraoke.com")
|
|
256
|
+
|
|
257
|
+
assert public_config is not None
|
|
258
|
+
assert public_config.id == "vocalstar"
|
|
259
|
+
|
|
260
|
+
# Tests for tenant_exists()
|
|
261
|
+
def test_tenant_exists_true(self, tenant_service, mock_storage):
|
|
262
|
+
"""Test returns True when config exists."""
|
|
263
|
+
mock_storage.file_exists.return_value = True
|
|
264
|
+
|
|
265
|
+
assert tenant_service.tenant_exists("vocalstar") is True
|
|
266
|
+
mock_storage.file_exists.assert_called_with("tenants/vocalstar/config.json")
|
|
267
|
+
|
|
268
|
+
def test_tenant_exists_false(self, tenant_service, mock_storage):
|
|
269
|
+
"""Test returns False when config doesn't exist."""
|
|
270
|
+
mock_storage.file_exists.return_value = False
|
|
271
|
+
|
|
272
|
+
assert tenant_service.tenant_exists("nonexistent") is False
|
|
273
|
+
|
|
274
|
+
# Tests for is_email_allowed_for_tenant()
|
|
275
|
+
def test_is_email_allowed_for_tenant_valid(self, tenant_service, mock_storage):
|
|
276
|
+
"""Test email allowed for valid domain."""
|
|
277
|
+
mock_storage.file_exists.return_value = True
|
|
278
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
279
|
+
|
|
280
|
+
assert tenant_service.is_email_allowed_for_tenant("vocalstar", "user@vocal-star.com") is True
|
|
281
|
+
|
|
282
|
+
def test_is_email_allowed_for_tenant_invalid(self, tenant_service, mock_storage):
|
|
283
|
+
"""Test email not allowed for invalid domain."""
|
|
284
|
+
mock_storage.file_exists.return_value = True
|
|
285
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
286
|
+
|
|
287
|
+
assert tenant_service.is_email_allowed_for_tenant("vocalstar", "user@gmail.com") is False
|
|
288
|
+
|
|
289
|
+
def test_is_email_allowed_for_tenant_not_found(self, tenant_service, mock_storage):
|
|
290
|
+
"""Test returns False when tenant not found."""
|
|
291
|
+
mock_storage.file_exists.return_value = False
|
|
292
|
+
|
|
293
|
+
assert tenant_service.is_email_allowed_for_tenant("nonexistent", "user@any.com") is False
|
|
294
|
+
|
|
295
|
+
# Tests for get_tenant_sender_email()
|
|
296
|
+
def test_get_tenant_sender_email_custom(self, tenant_service, mock_storage):
|
|
297
|
+
"""Test returns custom sender email when configured."""
|
|
298
|
+
mock_storage.file_exists.return_value = True
|
|
299
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
300
|
+
|
|
301
|
+
sender = tenant_service.get_tenant_sender_email("vocalstar")
|
|
302
|
+
|
|
303
|
+
assert sender == "vocalstar@nomadkaraoke.com"
|
|
304
|
+
|
|
305
|
+
def test_get_tenant_sender_email_default(self, tenant_service, mock_storage):
|
|
306
|
+
"""Test returns default sender when tenant not found."""
|
|
307
|
+
mock_storage.file_exists.return_value = False
|
|
308
|
+
|
|
309
|
+
sender = tenant_service.get_tenant_sender_email("nonexistent")
|
|
310
|
+
|
|
311
|
+
# Should match DEFAULT_SENDER_EMAIL constant (consistent with EmailService)
|
|
312
|
+
assert sender == "gen@nomadkaraoke.com"
|
|
313
|
+
|
|
314
|
+
# Tests for invalidate_cache()
|
|
315
|
+
def test_invalidate_cache_specific_tenant(self, tenant_service, mock_storage):
|
|
316
|
+
"""Test invalidating cache for specific tenant."""
|
|
317
|
+
mock_storage.file_exists.return_value = True
|
|
318
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
319
|
+
|
|
320
|
+
# Load into cache
|
|
321
|
+
tenant_service.get_tenant_config("vocalstar")
|
|
322
|
+
assert "vocalstar" in tenant_service._config_cache
|
|
323
|
+
|
|
324
|
+
# Invalidate
|
|
325
|
+
tenant_service.invalidate_cache("vocalstar")
|
|
326
|
+
|
|
327
|
+
assert "vocalstar" not in tenant_service._config_cache
|
|
328
|
+
assert "vocalstar" not in tenant_service._cache_times
|
|
329
|
+
|
|
330
|
+
def test_invalidate_cache_specific_tenant_clears_subdomain_map(self, tenant_service, mock_storage):
|
|
331
|
+
"""Test invalidating specific tenant also clears its subdomain map entry."""
|
|
332
|
+
mock_storage.file_exists.return_value = True
|
|
333
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
334
|
+
|
|
335
|
+
# Load into cache (this also populates subdomain map)
|
|
336
|
+
tenant_service.get_tenant_config("vocalstar")
|
|
337
|
+
assert "vocalstar.nomadkaraoke.com" in tenant_service._subdomain_map
|
|
338
|
+
assert tenant_service._subdomain_map["vocalstar.nomadkaraoke.com"] == "vocalstar"
|
|
339
|
+
|
|
340
|
+
# Invalidate specific tenant
|
|
341
|
+
tenant_service.invalidate_cache("vocalstar")
|
|
342
|
+
|
|
343
|
+
# Subdomain map entry should also be removed
|
|
344
|
+
assert "vocalstar.nomadkaraoke.com" not in tenant_service._subdomain_map
|
|
345
|
+
|
|
346
|
+
def test_invalidate_cache_all(self, tenant_service, mock_storage):
|
|
347
|
+
"""Test invalidating all caches."""
|
|
348
|
+
mock_storage.file_exists.return_value = True
|
|
349
|
+
mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
|
|
350
|
+
|
|
351
|
+
# Load into cache
|
|
352
|
+
tenant_service.get_tenant_config("vocalstar")
|
|
353
|
+
|
|
354
|
+
# Invalidate all
|
|
355
|
+
tenant_service.invalidate_cache()
|
|
356
|
+
|
|
357
|
+
assert len(tenant_service._config_cache) == 0
|
|
358
|
+
assert len(tenant_service._cache_times) == 0
|
|
359
|
+
assert len(tenant_service._subdomain_map) == 0
|
|
360
|
+
|
|
361
|
+
# Tests for get_asset_url()
|
|
362
|
+
def test_get_asset_url_success(self, tenant_service, mock_storage):
|
|
363
|
+
"""Test getting signed URL for existing asset."""
|
|
364
|
+
mock_storage.file_exists.return_value = True
|
|
365
|
+
|
|
366
|
+
url = tenant_service.get_asset_url("vocalstar", "logo.png")
|
|
367
|
+
|
|
368
|
+
assert url == "https://signed-url.example.com"
|
|
369
|
+
mock_storage.file_exists.assert_called_with("tenants/vocalstar/logo.png")
|
|
370
|
+
mock_storage.generate_signed_url.assert_called_with("tenants/vocalstar/logo.png", expiration_minutes=60)
|
|
371
|
+
|
|
372
|
+
def test_get_asset_url_not_found(self, tenant_service, mock_storage):
|
|
373
|
+
"""Test returns None when asset doesn't exist."""
|
|
374
|
+
mock_storage.file_exists.return_value = False
|
|
375
|
+
|
|
376
|
+
url = tenant_service.get_asset_url("vocalstar", "missing.png")
|
|
377
|
+
|
|
378
|
+
assert url is None
|
|
379
|
+
mock_storage.generate_signed_url.assert_not_called()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class TestGetTenantServiceSingleton:
|
|
383
|
+
"""Tests for the get_tenant_service() singleton function."""
|
|
384
|
+
|
|
385
|
+
def test_returns_same_instance(self):
|
|
386
|
+
"""Test that get_tenant_service returns singleton."""
|
|
387
|
+
# Reset the singleton
|
|
388
|
+
import backend.services.tenant_service as module
|
|
389
|
+
module._tenant_service = None
|
|
390
|
+
|
|
391
|
+
with patch.object(TenantService, '__init__', return_value=None):
|
|
392
|
+
service1 = get_tenant_service()
|
|
393
|
+
service2 = get_tenant_service()
|
|
394
|
+
|
|
395
|
+
assert service1 is service2
|
|
396
|
+
|
|
397
|
+
def test_thread_safe_initialization(self):
|
|
398
|
+
"""Test that singleton initialization is thread-safe."""
|
|
399
|
+
import threading
|
|
400
|
+
import backend.services.tenant_service as module
|
|
401
|
+
|
|
402
|
+
# Reset the singleton
|
|
403
|
+
module._tenant_service = None
|
|
404
|
+
|
|
405
|
+
instances = []
|
|
406
|
+
|
|
407
|
+
def get_instance():
|
|
408
|
+
with patch.object(TenantService, '__init__', return_value=None):
|
|
409
|
+
instances.append(get_tenant_service())
|
|
410
|
+
|
|
411
|
+
threads = [threading.Thread(target=get_instance) for _ in range(10)]
|
|
412
|
+
for t in threads:
|
|
413
|
+
t.start()
|
|
414
|
+
for t in threads:
|
|
415
|
+
t.join()
|
|
416
|
+
|
|
417
|
+
# All instances should be the same
|
|
418
|
+
assert all(i is instances[0] for i in instances)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for identifying test data.
|
|
3
|
+
|
|
4
|
+
Test data is generated by automated E2E tests and should be filterable
|
|
5
|
+
in admin dashboards to show only real user data by default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Email domains used by automated testing frameworks
|
|
9
|
+
TEST_EMAIL_DOMAINS = [
|
|
10
|
+
"inbox.testmail.app", # Used by E2E happy path tests
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_test_email(email: str) -> bool:
|
|
15
|
+
"""
|
|
16
|
+
Check if an email address belongs to automated test data.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
email: Email address to check
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True if the email matches a test email domain pattern
|
|
23
|
+
"""
|
|
24
|
+
if not email:
|
|
25
|
+
return False
|
|
26
|
+
email_lower = email.lower()
|
|
27
|
+
return any(email_lower.endswith(f"@{domain}") for domain in TEST_EMAIL_DOMAINS)
|
|
@@ -218,26 +218,36 @@ async def generate_screens(job_id: str) -> bool:
|
|
|
218
218
|
def _validate_prerequisites(job) -> bool:
|
|
219
219
|
"""
|
|
220
220
|
Validate that both audio and lyrics processing are complete.
|
|
221
|
-
|
|
221
|
+
|
|
222
222
|
Single Responsibility: Validation logic separated from main flow.
|
|
223
|
-
|
|
223
|
+
|
|
224
224
|
Args:
|
|
225
225
|
job: Job object
|
|
226
|
-
|
|
226
|
+
|
|
227
227
|
Returns:
|
|
228
228
|
True if prerequisites met, False otherwise
|
|
229
229
|
"""
|
|
230
|
+
# SAFETY NET: Enforce theme requirement at processing time
|
|
231
|
+
# This catches any jobs that somehow bypassed JobManager.create_job() validation
|
|
232
|
+
if not job.theme_id:
|
|
233
|
+
logger.error(
|
|
234
|
+
f"Job {job.job_id}: CRITICAL - No theme_id configured. "
|
|
235
|
+
"All jobs must have a theme to generate styled videos. "
|
|
236
|
+
"This job should have been rejected at creation time."
|
|
237
|
+
)
|
|
238
|
+
return False
|
|
239
|
+
|
|
230
240
|
audio_complete = job.state_data.get('audio_complete', False)
|
|
231
241
|
lyrics_complete = job.state_data.get('lyrics_complete', False)
|
|
232
|
-
|
|
242
|
+
|
|
233
243
|
if not audio_complete:
|
|
234
244
|
logger.error(f"Job {job.job_id}: Audio processing not complete")
|
|
235
245
|
return False
|
|
236
|
-
|
|
246
|
+
|
|
237
247
|
if not lyrics_complete:
|
|
238
248
|
logger.error(f"Job {job.job_id}: Lyrics processing not complete")
|
|
239
249
|
return False
|
|
240
|
-
|
|
250
|
+
|
|
241
251
|
if not job.artist or not job.title:
|
|
242
252
|
logger.error(f"Job {job.job_id}: Missing artist or title")
|
|
243
253
|
return False
|
backend/workers/video_worker.py
CHANGED
|
@@ -831,9 +831,12 @@ async def _handle_native_distribution(
|
|
|
831
831
|
# Don't fail the job - distribution is optional
|
|
832
832
|
|
|
833
833
|
# Upload to Google Drive using native API
|
|
834
|
+
# Skip if orchestrator already uploaded (gdrive_files already populated)
|
|
835
|
+
# This prevents duplicate uploads when using the orchestrator path
|
|
834
836
|
gdrive_folder_id = getattr(job, 'gdrive_folder_id', None)
|
|
835
|
-
|
|
836
|
-
|
|
837
|
+
existing_gdrive_files = result.get('gdrive_files')
|
|
838
|
+
|
|
839
|
+
if gdrive_folder_id and not existing_gdrive_files:
|
|
837
840
|
try:
|
|
838
841
|
from backend.services.gdrive_service import get_gdrive_service
|
|
839
842
|
|
|
@@ -869,7 +872,9 @@ async def _handle_native_distribution(
|
|
|
869
872
|
except Exception as e:
|
|
870
873
|
job_log.error(f"Native Google Drive upload failed: {e}", exc_info=True)
|
|
871
874
|
# Don't fail the job - distribution is optional
|
|
872
|
-
|
|
875
|
+
elif existing_gdrive_files:
|
|
876
|
+
job_log.info(f"Skipping Google Drive upload - orchestrator already uploaded {len(existing_gdrive_files)} files")
|
|
877
|
+
|
|
873
878
|
# Update job state_data with brand code and links
|
|
874
879
|
if brand_code or result.get('dropbox_link') or result.get('gdrive_files'):
|
|
875
880
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: karaoke-gen
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.101.0
|
|
4
4
|
Summary: Generate karaoke videos with synchronized lyrics. Handles the entire process from downloading audio and lyrics to creating the final video with title screens.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|