karaoke-gen 0.99.3__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.
Files changed (42) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +13 -2
  3. backend/api/routes/file_upload.py +42 -1
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +9 -1
  6. backend/api/routes/review.py +13 -6
  7. backend/api/routes/tenant.py +120 -0
  8. backend/api/routes/users.py +167 -245
  9. backend/main.py +6 -1
  10. backend/middleware/__init__.py +7 -1
  11. backend/middleware/tenant.py +192 -0
  12. backend/models/job.py +19 -3
  13. backend/models/tenant.py +208 -0
  14. backend/models/user.py +18 -0
  15. backend/services/email_service.py +253 -6
  16. backend/services/firestore_service.py +6 -0
  17. backend/services/job_manager.py +32 -1
  18. backend/services/stripe_service.py +61 -35
  19. backend/services/tenant_service.py +285 -0
  20. backend/services/user_service.py +85 -7
  21. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  22. backend/tests/test_admin_job_files.py +337 -0
  23. backend/tests/test_admin_job_reset.py +384 -0
  24. backend/tests/test_admin_job_update.py +326 -0
  25. backend/tests/test_email_service.py +233 -0
  26. backend/tests/test_impersonation.py +223 -0
  27. backend/tests/test_job_creation_regression.py +4 -0
  28. backend/tests/test_job_manager.py +146 -1
  29. backend/tests/test_made_for_you.py +2086 -0
  30. backend/tests/test_models.py +139 -0
  31. backend/tests/test_tenant_api.py +350 -0
  32. backend/tests/test_tenant_middleware.py +345 -0
  33. backend/tests/test_tenant_models.py +406 -0
  34. backend/tests/test_tenant_service.py +418 -0
  35. backend/workers/video_worker.py +8 -3
  36. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  37. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +42 -28
  38. lyrics_transcriber/frontend/src/api.ts +13 -5
  39. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  40. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  41. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  42. {karaoke_gen-0.99.3.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