karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__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 (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,350 @@
1
+ """
2
+ Unit tests for tenant API routes.
3
+
4
+ Tests the /api/tenant/config endpoints.
5
+
6
+ Note: We need to patch get_tenant_service in both the routes module AND the
7
+ middleware module since the middleware runs first and also calls get_tenant_service.
8
+ """
9
+
10
+ import pytest
11
+ from unittest.mock import MagicMock, patch
12
+ from fastapi.testclient import TestClient
13
+
14
+ from backend.models.tenant import (
15
+ TenantConfig,
16
+ TenantBranding,
17
+ TenantFeatures,
18
+ TenantDefaults,
19
+ TenantAuth,
20
+ TenantPublicConfig,
21
+ )
22
+
23
+
24
+ # Sample tenant config for mocking
25
+ SAMPLE_VOCALSTAR_CONFIG = TenantConfig(
26
+ id="vocalstar",
27
+ name="Vocal Star",
28
+ subdomain="vocalstar.nomadkaraoke.com",
29
+ is_active=True,
30
+ branding=TenantBranding(
31
+ logo_url="https://example.com/logo.png",
32
+ primary_color="#ffff00",
33
+ secondary_color="#006CF9",
34
+ site_title="Vocal Star Karaoke Generator",
35
+ ),
36
+ features=TenantFeatures(
37
+ audio_search=False,
38
+ youtube_url=False,
39
+ youtube_upload=False,
40
+ dropbox_upload=False,
41
+ gdrive_upload=False,
42
+ theme_selection=False,
43
+ ),
44
+ defaults=TenantDefaults(
45
+ theme_id="vocalstar",
46
+ locked_theme="vocalstar",
47
+ distribution_mode="download_only",
48
+ ),
49
+ auth=TenantAuth(
50
+ allowed_email_domains=["vocal-star.com", "vocalstarmusic.com"],
51
+ require_email_domain=True,
52
+ sender_email="vocalstar@nomadkaraoke.com",
53
+ ),
54
+ )
55
+
56
+ INACTIVE_CONFIG = TenantConfig(
57
+ id="inactive",
58
+ name="Inactive Tenant",
59
+ subdomain="inactive.nomadkaraoke.com",
60
+ is_active=False,
61
+ )
62
+
63
+
64
+ class TestTenantConfigEndpoint:
65
+ """Tests for GET /api/tenant/config endpoint."""
66
+
67
+ @pytest.fixture
68
+ def mock_tenant_service(self):
69
+ """Mock the tenant service in both routes and middleware."""
70
+ service = MagicMock()
71
+ # Patch in both places: routes AND middleware
72
+ with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
73
+ patch("backend.middleware.tenant.get_tenant_service", return_value=service):
74
+ yield service
75
+
76
+ @pytest.fixture
77
+ def client(self, mock_tenant_service):
78
+ """Create test client with mocked tenant service."""
79
+ from backend.main import app
80
+ return TestClient(app)
81
+
82
+ def test_get_config_default(self, client, mock_tenant_service):
83
+ """Test returns default config when no tenant detected."""
84
+ mock_tenant_service.tenant_exists.return_value = False
85
+
86
+ response = client.get("/api/tenant/config")
87
+
88
+ assert response.status_code == 200
89
+ data = response.json()
90
+ assert data["tenant"] is None
91
+ assert data["is_default"] is True
92
+
93
+ def test_get_config_with_query_param(self, client, mock_tenant_service):
94
+ """Test tenant detection via query parameter."""
95
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
96
+ mock_tenant_service.get_public_config.return_value = public_config
97
+
98
+ response = client.get("/api/tenant/config?tenant=vocalstar")
99
+
100
+ assert response.status_code == 200
101
+ data = response.json()
102
+ assert data["tenant"]["id"] == "vocalstar"
103
+ assert data["tenant"]["name"] == "Vocal Star"
104
+ assert data["is_default"] is False
105
+
106
+ def test_get_config_with_header(self, client, mock_tenant_service):
107
+ """Test tenant detection via X-Tenant-ID header."""
108
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
109
+ mock_tenant_service.get_public_config.return_value = public_config
110
+
111
+ response = client.get(
112
+ "/api/tenant/config",
113
+ headers={"X-Tenant-ID": "vocalstar"},
114
+ )
115
+
116
+ assert response.status_code == 200
117
+ data = response.json()
118
+ assert data["tenant"]["id"] == "vocalstar"
119
+ assert data["is_default"] is False
120
+
121
+ def test_get_config_query_param_takes_priority(self, client, mock_tenant_service):
122
+ """Test query param takes priority over header."""
123
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
124
+ mock_tenant_service.get_public_config.return_value = public_config
125
+
126
+ response = client.get(
127
+ "/api/tenant/config?tenant=vocalstar",
128
+ headers={"X-Tenant-ID": "other"},
129
+ )
130
+
131
+ assert response.status_code == 200
132
+ # Should use vocalstar from query param
133
+ mock_tenant_service.get_public_config.assert_called_with("vocalstar")
134
+
135
+ def test_get_config_tenant_not_found(self, client, mock_tenant_service):
136
+ """Test returns default when tenant not found."""
137
+ mock_tenant_service.get_public_config.return_value = None
138
+
139
+ response = client.get("/api/tenant/config?tenant=nonexistent")
140
+
141
+ assert response.status_code == 200
142
+ data = response.json()
143
+ assert data["tenant"] is None
144
+ assert data["is_default"] is True
145
+
146
+ def test_get_config_inactive_tenant(self, client, mock_tenant_service):
147
+ """Test returns default when tenant is inactive."""
148
+ inactive_public = TenantPublicConfig.from_config(INACTIVE_CONFIG)
149
+ mock_tenant_service.get_public_config.return_value = inactive_public
150
+
151
+ response = client.get("/api/tenant/config?tenant=inactive")
152
+
153
+ assert response.status_code == 200
154
+ data = response.json()
155
+ assert data["tenant"] is None
156
+ assert data["is_default"] is True
157
+
158
+ def test_get_config_includes_branding(self, client, mock_tenant_service):
159
+ """Test response includes full branding config."""
160
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
161
+ mock_tenant_service.get_public_config.return_value = public_config
162
+
163
+ response = client.get("/api/tenant/config?tenant=vocalstar")
164
+
165
+ assert response.status_code == 200
166
+ data = response.json()
167
+ branding = data["tenant"]["branding"]
168
+ assert branding["primary_color"] == "#ffff00"
169
+ assert branding["secondary_color"] == "#006CF9"
170
+ assert branding["site_title"] == "Vocal Star Karaoke Generator"
171
+
172
+ def test_get_config_includes_features(self, client, mock_tenant_service):
173
+ """Test response includes feature flags."""
174
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
175
+ mock_tenant_service.get_public_config.return_value = public_config
176
+
177
+ response = client.get("/api/tenant/config?tenant=vocalstar")
178
+
179
+ assert response.status_code == 200
180
+ data = response.json()
181
+ features = data["tenant"]["features"]
182
+ assert features["audio_search"] is False
183
+ assert features["file_upload"] is True
184
+ assert features["youtube_upload"] is False
185
+
186
+ def test_get_config_includes_defaults(self, client, mock_tenant_service):
187
+ """Test response includes default settings."""
188
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
189
+ mock_tenant_service.get_public_config.return_value = public_config
190
+
191
+ response = client.get("/api/tenant/config?tenant=vocalstar")
192
+
193
+ assert response.status_code == 200
194
+ data = response.json()
195
+ defaults = data["tenant"]["defaults"]
196
+ assert defaults["theme_id"] == "vocalstar"
197
+ assert defaults["locked_theme"] == "vocalstar"
198
+ assert defaults["distribution_mode"] == "download_only"
199
+
200
+ def test_get_config_includes_allowed_domains(self, client, mock_tenant_service):
201
+ """Test response includes allowed email domains."""
202
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
203
+ mock_tenant_service.get_public_config.return_value = public_config
204
+
205
+ response = client.get("/api/tenant/config?tenant=vocalstar")
206
+
207
+ assert response.status_code == 200
208
+ data = response.json()
209
+ assert "vocal-star.com" in data["tenant"]["allowed_email_domains"]
210
+ assert "vocalstarmusic.com" in data["tenant"]["allowed_email_domains"]
211
+
212
+
213
+ class TestTenantConfigByIdEndpoint:
214
+ """Tests for GET /api/tenant/config/{tenant_id} endpoint."""
215
+
216
+ @pytest.fixture
217
+ def mock_tenant_service(self):
218
+ """Mock the tenant service in both routes and middleware."""
219
+ service = MagicMock()
220
+ with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
221
+ patch("backend.middleware.tenant.get_tenant_service", return_value=service):
222
+ yield service
223
+
224
+ @pytest.fixture
225
+ def client(self, mock_tenant_service):
226
+ """Create test client with mocked tenant service."""
227
+ from backend.main import app
228
+ return TestClient(app)
229
+
230
+ def test_get_config_by_id_success(self, client, mock_tenant_service):
231
+ """Test getting config by explicit ID."""
232
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
233
+ mock_tenant_service.get_public_config.return_value = public_config
234
+
235
+ response = client.get("/api/tenant/config/vocalstar")
236
+
237
+ assert response.status_code == 200
238
+ data = response.json()
239
+ assert data["tenant"]["id"] == "vocalstar"
240
+ assert data["is_default"] is False
241
+
242
+ def test_get_config_by_id_not_found(self, client, mock_tenant_service):
243
+ """Test returns default when tenant ID not found."""
244
+ mock_tenant_service.get_public_config.return_value = None
245
+
246
+ response = client.get("/api/tenant/config/nonexistent")
247
+
248
+ assert response.status_code == 200
249
+ data = response.json()
250
+ assert data["tenant"] is None
251
+ assert data["is_default"] is True
252
+
253
+ def test_get_config_by_id_inactive(self, client, mock_tenant_service):
254
+ """Test returns default when tenant is inactive."""
255
+ inactive_public = TenantPublicConfig.from_config(INACTIVE_CONFIG)
256
+ mock_tenant_service.get_public_config.return_value = inactive_public
257
+
258
+ response = client.get("/api/tenant/config/inactive")
259
+
260
+ assert response.status_code == 200
261
+ data = response.json()
262
+ assert data["tenant"] is None
263
+ assert data["is_default"] is True
264
+
265
+
266
+ class TestTenantAssetEndpoint:
267
+ """Tests for GET /api/tenant/asset/{tenant_id}/{asset_name} endpoint."""
268
+
269
+ @pytest.fixture
270
+ def mock_tenant_service(self):
271
+ """Mock the tenant service in both routes and middleware."""
272
+ service = MagicMock()
273
+ with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
274
+ patch("backend.middleware.tenant.get_tenant_service", return_value=service):
275
+ yield service
276
+
277
+ @pytest.fixture
278
+ def client(self, mock_tenant_service):
279
+ """Create test client with mocked tenant service."""
280
+ from backend.main import app
281
+ return TestClient(app)
282
+
283
+ def test_get_asset_redirects(self, client, mock_tenant_service):
284
+ """Test asset endpoint redirects to signed URL."""
285
+ mock_tenant_service.get_asset_url.return_value = "https://storage.googleapis.com/signed-url"
286
+
287
+ response = client.get(
288
+ "/api/tenant/asset/vocalstar/logo.png",
289
+ follow_redirects=False,
290
+ )
291
+
292
+ # Should redirect (307 Temporary Redirect is FastAPI default)
293
+ assert response.status_code == 307
294
+ assert "storage.googleapis.com" in response.headers["location"]
295
+
296
+ def test_get_asset_not_found(self, client, mock_tenant_service):
297
+ """Test returns 404 when asset not found."""
298
+ mock_tenant_service.get_asset_url.return_value = None
299
+
300
+ response = client.get("/api/tenant/asset/vocalstar/missing.png")
301
+
302
+ assert response.status_code == 404
303
+ assert "not found" in response.json()["detail"].lower()
304
+
305
+
306
+ class TestTenantConfigResponseSchema:
307
+ """Tests for response schema validation."""
308
+
309
+ @pytest.fixture
310
+ def mock_tenant_service(self):
311
+ """Mock the tenant service in both routes and middleware."""
312
+ service = MagicMock()
313
+ with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
314
+ patch("backend.middleware.tenant.get_tenant_service", return_value=service):
315
+ yield service
316
+
317
+ @pytest.fixture
318
+ def client(self, mock_tenant_service):
319
+ """Create test client with mocked tenant service."""
320
+ from backend.main import app
321
+ return TestClient(app)
322
+
323
+ def test_response_has_required_fields(self, client, mock_tenant_service):
324
+ """Test response always has required fields."""
325
+ mock_tenant_service.tenant_exists.return_value = False
326
+
327
+ response = client.get("/api/tenant/config")
328
+
329
+ assert response.status_code == 200
330
+ data = response.json()
331
+ assert "tenant" in data
332
+ assert "is_default" in data
333
+
334
+ def test_tenant_config_excludes_sensitive_fields(self, client, mock_tenant_service):
335
+ """Test public config doesn't include sensitive auth data."""
336
+ public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
337
+ mock_tenant_service.get_public_config.return_value = public_config
338
+
339
+ response = client.get("/api/tenant/config?tenant=vocalstar")
340
+
341
+ assert response.status_code == 200
342
+ data = response.json()
343
+ tenant = data["tenant"]
344
+
345
+ # Should NOT have full auth object
346
+ assert "auth" not in tenant
347
+ # Should NOT have sensitive defaults
348
+ assert tenant["defaults"].get("brand_prefix") is None
349
+ # Should have allowed_email_domains at top level
350
+ assert "allowed_email_domains" in tenant
@@ -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