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,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for TenantMiddleware.
|
|
3
|
+
|
|
4
|
+
Tests tenant detection from headers, query params, and subdomains,
|
|
5
|
+
as well as request state attachment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from unittest.mock import MagicMock, patch, AsyncMock
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.datastructures import Headers
|
|
14
|
+
from starlette.responses import Response
|
|
15
|
+
|
|
16
|
+
from backend.middleware.tenant import (
|
|
17
|
+
TenantMiddleware,
|
|
18
|
+
get_tenant_from_request,
|
|
19
|
+
get_tenant_config_from_request,
|
|
20
|
+
NON_TENANT_SUBDOMAINS,
|
|
21
|
+
)
|
|
22
|
+
from backend.models.tenant import TenantConfig, TenantFeatures
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Sample tenant config for mocking
|
|
26
|
+
SAMPLE_CONFIG = TenantConfig(
|
|
27
|
+
id="vocalstar",
|
|
28
|
+
name="Vocal Star",
|
|
29
|
+
subdomain="vocalstar.nomadkaraoke.com",
|
|
30
|
+
is_active=True,
|
|
31
|
+
features=TenantFeatures(audio_search=False),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
INACTIVE_CONFIG = TenantConfig(
|
|
35
|
+
id="inactive",
|
|
36
|
+
name="Inactive",
|
|
37
|
+
subdomain="inactive.nomadkaraoke.com",
|
|
38
|
+
is_active=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MockRequest:
|
|
43
|
+
"""Mock Request object for testing."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, headers=None, query_params=None):
|
|
46
|
+
self.headers = headers or {}
|
|
47
|
+
self.query_params = query_params or {}
|
|
48
|
+
self.url = MagicMock()
|
|
49
|
+
self.url.path = "/api/test"
|
|
50
|
+
self.state = MagicMock()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestTenantMiddleware:
|
|
54
|
+
"""Tests for TenantMiddleware class."""
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def middleware(self):
|
|
58
|
+
"""Create middleware instance."""
|
|
59
|
+
app = MagicMock()
|
|
60
|
+
return TenantMiddleware(app)
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def mock_tenant_service(self):
|
|
64
|
+
"""Create mock tenant service."""
|
|
65
|
+
with patch("backend.middleware.tenant.get_tenant_service") as mock:
|
|
66
|
+
service = MagicMock()
|
|
67
|
+
mock.return_value = service
|
|
68
|
+
yield service
|
|
69
|
+
|
|
70
|
+
# Tests for _extract_tenant_from_host()
|
|
71
|
+
def test_extract_tenant_from_host_standard_subdomain(self, middleware, mock_tenant_service):
|
|
72
|
+
"""Test extracting tenant from vocalstar.nomadkaraoke.com."""
|
|
73
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
74
|
+
|
|
75
|
+
tenant_id = middleware._extract_tenant_from_host("vocalstar.nomadkaraoke.com")
|
|
76
|
+
|
|
77
|
+
assert tenant_id == "vocalstar"
|
|
78
|
+
|
|
79
|
+
def test_extract_tenant_from_host_gen_subdomain(self, middleware, mock_tenant_service):
|
|
80
|
+
"""Test extracting tenant from vocalstar.gen.nomadkaraoke.com."""
|
|
81
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
82
|
+
|
|
83
|
+
tenant_id = middleware._extract_tenant_from_host("vocalstar.gen.nomadkaraoke.com")
|
|
84
|
+
|
|
85
|
+
assert tenant_id == "vocalstar"
|
|
86
|
+
|
|
87
|
+
def test_extract_tenant_from_host_with_port(self, middleware, mock_tenant_service):
|
|
88
|
+
"""Test extracting tenant when host includes port."""
|
|
89
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
90
|
+
|
|
91
|
+
tenant_id = middleware._extract_tenant_from_host("vocalstar.nomadkaraoke.com:443")
|
|
92
|
+
|
|
93
|
+
assert tenant_id == "vocalstar"
|
|
94
|
+
|
|
95
|
+
def test_extract_tenant_from_host_case_insensitive(self, middleware, mock_tenant_service):
|
|
96
|
+
"""Test host parsing is case insensitive."""
|
|
97
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
98
|
+
|
|
99
|
+
tenant_id = middleware._extract_tenant_from_host("VOCALSTAR.NomadKaraoke.COM")
|
|
100
|
+
|
|
101
|
+
assert tenant_id == "vocalstar"
|
|
102
|
+
|
|
103
|
+
def test_extract_tenant_from_host_empty(self, middleware):
|
|
104
|
+
"""Test returns None for empty host."""
|
|
105
|
+
tenant_id = middleware._extract_tenant_from_host("")
|
|
106
|
+
|
|
107
|
+
assert tenant_id is None
|
|
108
|
+
|
|
109
|
+
def test_extract_tenant_from_host_localhost(self, middleware):
|
|
110
|
+
"""Test returns None for localhost."""
|
|
111
|
+
tenant_id = middleware._extract_tenant_from_host("localhost:3000")
|
|
112
|
+
|
|
113
|
+
assert tenant_id is None
|
|
114
|
+
|
|
115
|
+
def test_extract_tenant_from_host_non_nomad_domain(self, middleware):
|
|
116
|
+
"""Test returns None for non-nomadkaraoke.com domains."""
|
|
117
|
+
tenant_id = middleware._extract_tenant_from_host("example.com")
|
|
118
|
+
|
|
119
|
+
assert tenant_id is None
|
|
120
|
+
|
|
121
|
+
def test_extract_tenant_from_host_base_domain(self, middleware):
|
|
122
|
+
"""Test returns None for nomadkaraoke.com without subdomain."""
|
|
123
|
+
tenant_id = middleware._extract_tenant_from_host("nomadkaraoke.com")
|
|
124
|
+
|
|
125
|
+
assert tenant_id is None
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("subdomain", NON_TENANT_SUBDOMAINS)
|
|
128
|
+
def test_extract_tenant_from_host_non_tenant_subdomains(self, middleware, subdomain):
|
|
129
|
+
"""Test returns None for known non-tenant subdomains."""
|
|
130
|
+
host = f"{subdomain}.nomadkaraoke.com"
|
|
131
|
+
|
|
132
|
+
tenant_id = middleware._extract_tenant_from_host(host)
|
|
133
|
+
|
|
134
|
+
assert tenant_id is None
|
|
135
|
+
|
|
136
|
+
def test_extract_tenant_from_host_tenant_not_exists(self, middleware, mock_tenant_service):
|
|
137
|
+
"""Test returns None when tenant doesn't exist in GCS."""
|
|
138
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
139
|
+
|
|
140
|
+
tenant_id = middleware._extract_tenant_from_host("nonexistent.nomadkaraoke.com")
|
|
141
|
+
|
|
142
|
+
assert tenant_id is None
|
|
143
|
+
|
|
144
|
+
# Tests for _extract_tenant_id()
|
|
145
|
+
def test_extract_tenant_id_from_header(self, middleware):
|
|
146
|
+
"""Test X-Tenant-ID header takes priority."""
|
|
147
|
+
request = MockRequest(
|
|
148
|
+
headers={"X-Tenant-ID": "vocalstar", "Host": "other.nomadkaraoke.com"},
|
|
149
|
+
query_params={"tenant": "different"},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
153
|
+
|
|
154
|
+
assert tenant_id == "vocalstar"
|
|
155
|
+
|
|
156
|
+
def test_extract_tenant_id_header_normalized(self, middleware):
|
|
157
|
+
"""Test header value is normalized (lowercased, stripped)."""
|
|
158
|
+
request = MockRequest(headers={"X-Tenant-ID": " VOCALSTAR "})
|
|
159
|
+
|
|
160
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
161
|
+
|
|
162
|
+
assert tenant_id == "vocalstar"
|
|
163
|
+
|
|
164
|
+
@patch.dict(os.environ, {"ENV": ""})
|
|
165
|
+
def test_extract_tenant_id_from_query_param_dev(self, middleware):
|
|
166
|
+
"""Test query param works in non-production."""
|
|
167
|
+
request = MockRequest(query_params={"tenant": "vocalstar"})
|
|
168
|
+
|
|
169
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
170
|
+
|
|
171
|
+
assert tenant_id == "vocalstar"
|
|
172
|
+
|
|
173
|
+
@patch.dict(os.environ, {"ENV": "production"})
|
|
174
|
+
def test_extract_tenant_id_query_param_disabled_in_prod(self, middleware, mock_tenant_service):
|
|
175
|
+
"""Test query param is ignored in production."""
|
|
176
|
+
# Need to reimport to pick up new env value
|
|
177
|
+
import importlib
|
|
178
|
+
import backend.middleware.tenant as tenant_module
|
|
179
|
+
|
|
180
|
+
# Save original value
|
|
181
|
+
original_is_prod = tenant_module.IS_PRODUCTION
|
|
182
|
+
|
|
183
|
+
# Temporarily set to True for this test
|
|
184
|
+
tenant_module.IS_PRODUCTION = True
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
188
|
+
request = MockRequest(
|
|
189
|
+
query_params={"tenant": "vocalstar"},
|
|
190
|
+
headers={"Host": "gen.nomadkaraoke.com"},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
194
|
+
|
|
195
|
+
# Should not use query param, and gen subdomain is non-tenant
|
|
196
|
+
assert tenant_id is None
|
|
197
|
+
finally:
|
|
198
|
+
# Restore
|
|
199
|
+
tenant_module.IS_PRODUCTION = original_is_prod
|
|
200
|
+
|
|
201
|
+
def test_extract_tenant_id_from_host(self, middleware, mock_tenant_service):
|
|
202
|
+
"""Test falls back to host header subdomain detection."""
|
|
203
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
204
|
+
request = MockRequest(headers={"Host": "vocalstar.nomadkaraoke.com"})
|
|
205
|
+
|
|
206
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
207
|
+
|
|
208
|
+
assert tenant_id == "vocalstar"
|
|
209
|
+
|
|
210
|
+
def test_extract_tenant_id_no_tenant(self, middleware, mock_tenant_service):
|
|
211
|
+
"""Test returns None when no tenant detected."""
|
|
212
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
213
|
+
request = MockRequest(headers={"Host": "gen.nomadkaraoke.com"})
|
|
214
|
+
|
|
215
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
216
|
+
|
|
217
|
+
assert tenant_id is None
|
|
218
|
+
|
|
219
|
+
# Tests for dispatch()
|
|
220
|
+
@pytest.mark.asyncio
|
|
221
|
+
async def test_dispatch_attaches_tenant_to_state(self, middleware, mock_tenant_service):
|
|
222
|
+
"""Test middleware attaches tenant info to request.state."""
|
|
223
|
+
mock_tenant_service.get_tenant_config.return_value = SAMPLE_CONFIG
|
|
224
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
225
|
+
|
|
226
|
+
request = MockRequest(headers={"X-Tenant-ID": "vocalstar"})
|
|
227
|
+
response = Response(content="OK")
|
|
228
|
+
call_next = AsyncMock(return_value=response)
|
|
229
|
+
|
|
230
|
+
result = await middleware.dispatch(request, call_next)
|
|
231
|
+
|
|
232
|
+
assert request.state.tenant_id == "vocalstar"
|
|
233
|
+
assert request.state.tenant_config == SAMPLE_CONFIG
|
|
234
|
+
|
|
235
|
+
@pytest.mark.asyncio
|
|
236
|
+
async def test_dispatch_adds_header_to_response(self, middleware, mock_tenant_service):
|
|
237
|
+
"""Test middleware adds X-Tenant-ID header to response."""
|
|
238
|
+
mock_tenant_service.get_tenant_config.return_value = SAMPLE_CONFIG
|
|
239
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
240
|
+
|
|
241
|
+
request = MockRequest(headers={"X-Tenant-ID": "vocalstar"})
|
|
242
|
+
response = Response(content="OK")
|
|
243
|
+
call_next = AsyncMock(return_value=response)
|
|
244
|
+
|
|
245
|
+
result = await middleware.dispatch(request, call_next)
|
|
246
|
+
|
|
247
|
+
assert result.headers.get("X-Tenant-ID") == "vocalstar"
|
|
248
|
+
|
|
249
|
+
@pytest.mark.asyncio
|
|
250
|
+
async def test_dispatch_no_tenant(self, middleware, mock_tenant_service):
|
|
251
|
+
"""Test middleware handles no tenant gracefully."""
|
|
252
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
253
|
+
|
|
254
|
+
request = MockRequest(headers={"Host": "gen.nomadkaraoke.com"})
|
|
255
|
+
response = Response(content="OK")
|
|
256
|
+
call_next = AsyncMock(return_value=response)
|
|
257
|
+
|
|
258
|
+
result = await middleware.dispatch(request, call_next)
|
|
259
|
+
|
|
260
|
+
assert request.state.tenant_id is None
|
|
261
|
+
assert request.state.tenant_config is None
|
|
262
|
+
assert "X-Tenant-ID" not in result.headers
|
|
263
|
+
|
|
264
|
+
@pytest.mark.asyncio
|
|
265
|
+
async def test_dispatch_inactive_tenant(self, middleware, mock_tenant_service):
|
|
266
|
+
"""Test middleware treats inactive tenant as no tenant."""
|
|
267
|
+
mock_tenant_service.get_tenant_config.return_value = INACTIVE_CONFIG
|
|
268
|
+
|
|
269
|
+
request = MockRequest(headers={"X-Tenant-ID": "inactive"})
|
|
270
|
+
response = Response(content="OK")
|
|
271
|
+
call_next = AsyncMock(return_value=response)
|
|
272
|
+
|
|
273
|
+
result = await middleware.dispatch(request, call_next)
|
|
274
|
+
|
|
275
|
+
# Inactive tenant should be treated as default
|
|
276
|
+
assert request.state.tenant_id is None
|
|
277
|
+
assert request.state.tenant_config is None
|
|
278
|
+
|
|
279
|
+
@pytest.mark.asyncio
|
|
280
|
+
async def test_dispatch_calls_next(self, middleware, mock_tenant_service):
|
|
281
|
+
"""Test middleware calls the next handler."""
|
|
282
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
283
|
+
|
|
284
|
+
request = MockRequest(headers={"Host": "gen.nomadkaraoke.com"})
|
|
285
|
+
response = Response(content="OK")
|
|
286
|
+
call_next = AsyncMock(return_value=response)
|
|
287
|
+
|
|
288
|
+
await middleware.dispatch(request, call_next)
|
|
289
|
+
|
|
290
|
+
call_next.assert_called_once_with(request)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class TestHelperFunctions:
|
|
294
|
+
"""Tests for helper functions."""
|
|
295
|
+
|
|
296
|
+
def test_get_tenant_from_request(self):
|
|
297
|
+
"""Test get_tenant_from_request helper."""
|
|
298
|
+
request = MagicMock()
|
|
299
|
+
request.state.tenant_id = "vocalstar"
|
|
300
|
+
|
|
301
|
+
assert get_tenant_from_request(request) == "vocalstar"
|
|
302
|
+
|
|
303
|
+
def test_get_tenant_from_request_none(self):
|
|
304
|
+
"""Test get_tenant_from_request when no tenant."""
|
|
305
|
+
request = MagicMock()
|
|
306
|
+
del request.state.tenant_id # Simulate missing attribute
|
|
307
|
+
|
|
308
|
+
assert get_tenant_from_request(request) is None
|
|
309
|
+
|
|
310
|
+
def test_get_tenant_config_from_request(self):
|
|
311
|
+
"""Test get_tenant_config_from_request helper."""
|
|
312
|
+
request = MagicMock()
|
|
313
|
+
request.state.tenant_config = SAMPLE_CONFIG
|
|
314
|
+
|
|
315
|
+
config = get_tenant_config_from_request(request)
|
|
316
|
+
|
|
317
|
+
assert config == SAMPLE_CONFIG
|
|
318
|
+
assert config.features.audio_search is False
|
|
319
|
+
|
|
320
|
+
def test_get_tenant_config_from_request_none(self):
|
|
321
|
+
"""Test get_tenant_config_from_request when no config."""
|
|
322
|
+
request = MagicMock()
|
|
323
|
+
del request.state.tenant_config
|
|
324
|
+
|
|
325
|
+
assert get_tenant_config_from_request(request) is None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TestNonTenantSubdomains:
|
|
329
|
+
"""Tests for NON_TENANT_SUBDOMAINS constant."""
|
|
330
|
+
|
|
331
|
+
def test_gen_is_non_tenant(self):
|
|
332
|
+
"""Test 'gen' is in non-tenant subdomains."""
|
|
333
|
+
assert "gen" in NON_TENANT_SUBDOMAINS
|
|
334
|
+
|
|
335
|
+
def test_api_is_non_tenant(self):
|
|
336
|
+
"""Test 'api' is in non-tenant subdomains."""
|
|
337
|
+
assert "api" in NON_TENANT_SUBDOMAINS
|
|
338
|
+
|
|
339
|
+
def test_www_is_non_tenant(self):
|
|
340
|
+
"""Test 'www' is in non-tenant subdomains."""
|
|
341
|
+
assert "www" in NON_TENANT_SUBDOMAINS
|
|
342
|
+
|
|
343
|
+
def test_admin_is_non_tenant(self):
|
|
344
|
+
"""Test 'admin' is in non-tenant subdomains."""
|
|
345
|
+
assert "admin" in NON_TENANT_SUBDOMAINS
|