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
@@ -913,6 +913,145 @@ class TestJobWithFullTwoPhaseConfig:
913
913
  assert "title" in job.file_urls["screens"]
914
914
 
915
915
 
916
+ class TestMadeForYouFields:
917
+ """Tests for made-for-you order tracking fields."""
918
+
919
+ def test_job_has_made_for_you_field(self):
920
+ """Test Job model has made_for_you field with default False."""
921
+ job = Job(
922
+ job_id="test123",
923
+ status=JobStatus.PENDING,
924
+ created_at=datetime.now(UTC),
925
+ updated_at=datetime.now(UTC),
926
+ )
927
+ assert hasattr(job, 'made_for_you')
928
+ assert job.made_for_you is False
929
+
930
+ def test_job_made_for_you_true(self):
931
+ """Test Job model can set made_for_you to True."""
932
+ job = Job(
933
+ job_id="test123",
934
+ status=JobStatus.PENDING,
935
+ created_at=datetime.now(UTC),
936
+ updated_at=datetime.now(UTC),
937
+ made_for_you=True,
938
+ )
939
+ assert job.made_for_you is True
940
+
941
+ def test_job_has_customer_email_field(self):
942
+ """Test Job model has customer_email field with default None."""
943
+ job = Job(
944
+ job_id="test123",
945
+ status=JobStatus.PENDING,
946
+ created_at=datetime.now(UTC),
947
+ updated_at=datetime.now(UTC),
948
+ )
949
+ assert hasattr(job, 'customer_email')
950
+ assert job.customer_email is None
951
+
952
+ def test_job_customer_email_set(self):
953
+ """Test Job model can set customer_email."""
954
+ job = Job(
955
+ job_id="test123",
956
+ status=JobStatus.PENDING,
957
+ created_at=datetime.now(UTC),
958
+ updated_at=datetime.now(UTC),
959
+ customer_email="customer@example.com",
960
+ )
961
+ assert job.customer_email == "customer@example.com"
962
+
963
+ def test_job_has_customer_notes_field(self):
964
+ """Test Job model has customer_notes field with default None."""
965
+ job = Job(
966
+ job_id="test123",
967
+ status=JobStatus.PENDING,
968
+ created_at=datetime.now(UTC),
969
+ updated_at=datetime.now(UTC),
970
+ )
971
+ assert hasattr(job, 'customer_notes')
972
+ assert job.customer_notes is None
973
+
974
+ def test_job_customer_notes_set(self):
975
+ """Test Job model can set customer_notes."""
976
+ job = Job(
977
+ job_id="test123",
978
+ status=JobStatus.PENDING,
979
+ created_at=datetime.now(UTC),
980
+ updated_at=datetime.now(UTC),
981
+ customer_notes="Please make it extra special!",
982
+ )
983
+ assert job.customer_notes == "Please make it extra special!"
984
+
985
+ def test_job_create_has_made_for_you_fields(self):
986
+ """Test JobCreate model has all made-for-you fields."""
987
+ job_create = JobCreate(
988
+ artist="Test Artist",
989
+ title="Test Song",
990
+ )
991
+ assert hasattr(job_create, 'made_for_you')
992
+ assert hasattr(job_create, 'customer_email')
993
+ assert hasattr(job_create, 'customer_notes')
994
+ assert job_create.made_for_you is False
995
+ assert job_create.customer_email is None
996
+ assert job_create.customer_notes is None
997
+
998
+ def test_job_create_with_made_for_you_config(self):
999
+ """Test JobCreate with full made-for-you configuration."""
1000
+ job_create = JobCreate(
1001
+ artist="Seether",
1002
+ title="Tonight",
1003
+ user_email="admin@nomadkaraoke.com",
1004
+ made_for_you=True,
1005
+ customer_email="customer@example.com",
1006
+ customer_notes="Wedding anniversary!",
1007
+ )
1008
+ assert job_create.made_for_you is True
1009
+ assert job_create.customer_email == "customer@example.com"
1010
+ assert job_create.customer_notes == "Wedding anniversary!"
1011
+ assert job_create.user_email == "admin@nomadkaraoke.com"
1012
+
1013
+ def test_made_for_you_serialization_roundtrip(self):
1014
+ """Test made-for-you fields survive dict serialization."""
1015
+ job = Job(
1016
+ job_id="test123",
1017
+ status=JobStatus.AWAITING_AUDIO_SELECTION,
1018
+ created_at=datetime.now(UTC),
1019
+ updated_at=datetime.now(UTC),
1020
+ made_for_you=True,
1021
+ customer_email="customer@example.com",
1022
+ customer_notes="Test notes",
1023
+ user_email="admin@nomadkaraoke.com",
1024
+ )
1025
+
1026
+ job_dict = job.model_dump()
1027
+
1028
+ assert job_dict['made_for_you'] is True
1029
+ assert job_dict['customer_email'] == "customer@example.com"
1030
+ assert job_dict['customer_notes'] == "Test notes"
1031
+
1032
+ def test_made_for_you_job_with_distribution_settings(self):
1033
+ """Test made-for-you job with distribution settings."""
1034
+ job = Job(
1035
+ job_id="test123",
1036
+ status=JobStatus.AWAITING_AUDIO_SELECTION,
1037
+ created_at=datetime.now(UTC),
1038
+ updated_at=datetime.now(UTC),
1039
+ made_for_you=True,
1040
+ customer_email="customer@example.com",
1041
+ user_email="admin@nomadkaraoke.com",
1042
+ enable_youtube_upload=True,
1043
+ dropbox_path="/Production/Ready To Upload",
1044
+ gdrive_folder_id="1ABC123",
1045
+ brand_prefix="NOMAD",
1046
+ )
1047
+
1048
+ assert job.made_for_you is True
1049
+ assert job.enable_youtube_upload is True
1050
+ assert job.dropbox_path == "/Production/Ready To Upload"
1051
+ assert job.gdrive_folder_id == "1ABC123"
1052
+ assert job.brand_prefix == "NOMAD"
1053
+
1054
+
916
1055
  if __name__ == "__main__":
917
1056
  pytest.main([__file__, "-v"])
918
1057
 
@@ -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