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,167 @@
1
+ """
2
+ Emulator integration tests for made-for-you order features.
3
+
4
+ Tests with real Firestore emulator via API endpoints to verify:
5
+ 1. Admin login token creation and verification
6
+ 2. Made-for-you job fields are properly stored
7
+
8
+ Run with: scripts/run-emulator-tests.sh
9
+ """
10
+ import pytest
11
+ import time
12
+
13
+ from .conftest import emulators_running
14
+
15
+ # Skip all tests if emulators not running
16
+ pytestmark = pytest.mark.skipif(
17
+ not emulators_running(),
18
+ reason="GCP emulators not running. Start with: scripts/start-emulators.sh"
19
+ )
20
+
21
+
22
+ class TestAdminLoginTokenIntegration:
23
+ """
24
+ Integration tests for admin login token feature via API.
25
+
26
+ These tests verify that admin login tokens can be verified
27
+ through the actual API endpoint.
28
+ """
29
+
30
+ def test_verify_invalid_token_returns_401(self, client):
31
+ """Invalid token returns 401 unauthorized."""
32
+ response = client.get("/api/users/auth/verify?token=invalid-token-xyz")
33
+ assert response.status_code == 401
34
+
35
+ def test_verify_missing_token_returns_422(self, client):
36
+ """Missing token parameter returns 422 validation error."""
37
+ response = client.get("/api/users/auth/verify")
38
+ assert response.status_code == 422
39
+
40
+ def test_verify_empty_token_returns_401(self, client):
41
+ """Empty token returns 401."""
42
+ response = client.get("/api/users/auth/verify?token=")
43
+ assert response.status_code == 401
44
+
45
+
46
+ class TestMadeForYouJobModel:
47
+ """
48
+ Tests for made-for-you job model fields.
49
+
50
+ Verifies that the Job model properly supports made_for_you fields.
51
+ """
52
+
53
+ def test_job_model_has_made_for_you_fields(self):
54
+ """Job model includes made_for_you and customer_email fields."""
55
+ from backend.models.job import Job, JobStatus
56
+ from datetime import datetime, timezone
57
+
58
+ # Create a made-for-you job directly
59
+ job = Job(
60
+ job_id="test-mfy-model",
61
+ status=JobStatus.PENDING,
62
+ created_at=datetime.now(timezone.utc),
63
+ updated_at=datetime.now(timezone.utc),
64
+ user_email="admin@nomadkaraoke.com",
65
+ made_for_you=True,
66
+ customer_email="customer@example.com",
67
+ customer_notes="Please make it perfect!",
68
+ )
69
+
70
+ assert job.made_for_you is True
71
+ assert job.customer_email == "customer@example.com"
72
+ assert job.customer_notes == "Please make it perfect!"
73
+
74
+ def test_job_model_defaults_made_for_you_false(self):
75
+ """Regular jobs default made_for_you to False."""
76
+ from backend.models.job import Job, JobStatus
77
+ from datetime import datetime, timezone
78
+
79
+ job = Job(
80
+ job_id="test-regular-model",
81
+ status=JobStatus.PENDING,
82
+ created_at=datetime.now(timezone.utc),
83
+ updated_at=datetime.now(timezone.utc),
84
+ user_email="user@example.com",
85
+ )
86
+
87
+ # Default should be False or None
88
+ assert job.made_for_you in [False, None]
89
+ assert job.customer_email is None
90
+
91
+
92
+ class TestOwnershipTransferLogic:
93
+ """
94
+ Tests for ownership transfer logic (unit-style, runs in emulator context).
95
+
96
+ These tests verify the conditional logic for made-for-you ownership transfer
97
+ by importing the job_manager and testing its behavior directly.
98
+ """
99
+
100
+ def test_ownership_transfer_condition_made_for_you_true(self):
101
+ """
102
+ Verify the condition: if made_for_you=True and customer_email exists,
103
+ ownership should transfer.
104
+ """
105
+ # Replicate the exact logic from _schedule_completion_email
106
+ class MockJob:
107
+ made_for_you = True
108
+ customer_email = "customer@example.com"
109
+ user_email = "admin@nomadkaraoke.com"
110
+ job_id = "test-123"
111
+
112
+ job = MockJob()
113
+
114
+ # The logic being tested
115
+ recipient_email = job.user_email
116
+ should_transfer = False
117
+
118
+ if job.made_for_you and job.customer_email:
119
+ recipient_email = job.customer_email
120
+ should_transfer = True
121
+
122
+ assert should_transfer is True
123
+ assert recipient_email == "customer@example.com"
124
+
125
+ def test_ownership_transfer_condition_made_for_you_false(self):
126
+ """
127
+ Verify: if made_for_you=False, no ownership transfer.
128
+ """
129
+ class MockJob:
130
+ made_for_you = False
131
+ customer_email = "customer@example.com"
132
+ user_email = "user@example.com"
133
+ job_id = "test-456"
134
+
135
+ job = MockJob()
136
+
137
+ recipient_email = job.user_email
138
+ should_transfer = False
139
+
140
+ if job.made_for_you and job.customer_email:
141
+ recipient_email = job.customer_email
142
+ should_transfer = True
143
+
144
+ assert should_transfer is False
145
+ assert recipient_email == "user@example.com"
146
+
147
+ def test_ownership_transfer_condition_missing_customer_email(self):
148
+ """
149
+ Verify: if made_for_you=True but no customer_email, no transfer.
150
+ """
151
+ class MockJob:
152
+ made_for_you = True
153
+ customer_email = None
154
+ user_email = "admin@nomadkaraoke.com"
155
+ job_id = "test-789"
156
+
157
+ job = MockJob()
158
+
159
+ recipient_email = job.user_email
160
+ should_transfer = False
161
+
162
+ if job.made_for_you and job.customer_email:
163
+ recipient_email = job.customer_email
164
+ should_transfer = True
165
+
166
+ assert should_transfer is False
167
+ assert recipient_email == "admin@nomadkaraoke.com"
@@ -0,0 +1,337 @@
1
+ """
2
+ Unit tests for admin job files endpoint.
3
+
4
+ Tests the GET /api/admin/jobs/{job_id}/files endpoint that returns
5
+ all files for a job with signed download URLs.
6
+ """
7
+ import pytest
8
+ from unittest.mock import Mock, patch
9
+ from fastapi.testclient import TestClient
10
+ from fastapi import FastAPI
11
+
12
+ from backend.api.routes.admin import router
13
+ from backend.api.dependencies import require_admin
14
+ from backend.models.job import Job, JobStatus
15
+
16
+
17
+ # Create a test app with the admin router
18
+ app = FastAPI()
19
+ app.include_router(router, prefix="/api")
20
+
21
+
22
+ def get_mock_admin():
23
+ """Override for require_admin dependency."""
24
+ from backend.api.dependencies import AuthResult, UserType
25
+ return AuthResult(
26
+ is_valid=True,
27
+ user_type=UserType.ADMIN,
28
+ remaining_uses=999,
29
+ message="Admin authenticated",
30
+ user_email="admin@example.com",
31
+ is_admin=True,
32
+ )
33
+
34
+
35
+ # Override the require_admin dependency
36
+ app.dependency_overrides[require_admin] = get_mock_admin
37
+
38
+
39
+ @pytest.fixture
40
+ def client():
41
+ """Create a test client."""
42
+ return TestClient(app)
43
+
44
+
45
+ @pytest.fixture
46
+ def mock_job_with_files():
47
+ """Create a mock job with comprehensive file_urls."""
48
+ job = Mock(spec=Job)
49
+ job.job_id = "test-job-123"
50
+ job.user_email = "user@example.com"
51
+ job.artist = "Test Artist"
52
+ job.title = "Test Song"
53
+ job.status = JobStatus.COMPLETE
54
+ job.file_urls = {
55
+ "input": "gs://bucket/jobs/test-job-123/input.flac",
56
+ "stems": {
57
+ "instrumental_clean": "gs://bucket/jobs/test-job-123/stems/instrumental_clean.flac",
58
+ "vocals": "gs://bucket/jobs/test-job-123/stems/vocals.flac",
59
+ },
60
+ "lyrics": {
61
+ "corrections": "gs://bucket/jobs/test-job-123/lyrics/corrections.json",
62
+ "lrc": "gs://bucket/jobs/test-job-123/lyrics/output.lrc",
63
+ },
64
+ "finals": {
65
+ "lossy_720p_mp4": "gs://bucket/jobs/test-job-123/finals/video_720p.mp4",
66
+ },
67
+ "youtube": {
68
+ "url": "https://youtube.com/watch?v=xyz789",
69
+ "video_id": "xyz789",
70
+ },
71
+ }
72
+ return job
73
+
74
+
75
+ @pytest.fixture
76
+ def mock_job_no_files():
77
+ """Create a mock job with empty file_urls."""
78
+ job = Mock(spec=Job)
79
+ job.job_id = "test-job-empty"
80
+ job.user_email = "user@example.com"
81
+ job.artist = "Empty Artist"
82
+ job.title = "Empty Song"
83
+ job.status = JobStatus.PENDING
84
+ job.file_urls = {}
85
+ return job
86
+
87
+
88
+ @pytest.fixture
89
+ def mock_job_partial_files():
90
+ """Create a mock job with only some files (partial processing)."""
91
+ job = Mock(spec=Job)
92
+ job.job_id = "test-job-partial"
93
+ job.user_email = "user@example.com"
94
+ job.artist = "Partial Artist"
95
+ job.title = "Partial Song"
96
+ job.status = JobStatus.SEPARATING_STAGE1
97
+ job.file_urls = {
98
+ "input": "gs://bucket/jobs/test-job-partial/input.flac",
99
+ "stems": {
100
+ "instrumental_clean": "gs://bucket/jobs/test-job-partial/stems/instrumental_clean.flac",
101
+ },
102
+ }
103
+ return job
104
+
105
+
106
+ class TestGetJobFiles:
107
+ """Tests for GET /api/admin/jobs/{job_id}/files endpoint."""
108
+
109
+ def test_returns_files_with_signed_urls(self, client, mock_job_with_files):
110
+ """Test that endpoint returns all files with signed URLs."""
111
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
112
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
113
+
114
+ # Setup JobManager mock
115
+ mock_jm = Mock()
116
+ mock_jm.get_job.return_value = mock_job_with_files
117
+ mock_jm_class.return_value = mock_jm
118
+
119
+ # Setup StorageService mock to return predictable signed URLs
120
+ mock_storage = Mock()
121
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
122
+ mock_storage_class.return_value = mock_storage
123
+
124
+ response = client.get(
125
+ "/api/admin/jobs/test-job-123/files",
126
+ headers={"Authorization": "Bearer admin-token"}
127
+ )
128
+
129
+ assert response.status_code == 200
130
+ data = response.json()
131
+ assert data["job_id"] == "test-job-123"
132
+ assert data["artist"] == "Test Artist"
133
+ assert data["title"] == "Test Song"
134
+ assert "files" in data
135
+ assert data["total_files"] > 0
136
+
137
+ def test_returns_correct_file_count(self, client, mock_job_with_files):
138
+ """Test that file count matches actual GCS files (excluding non-GCS entries)."""
139
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
140
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
141
+
142
+ mock_jm = Mock()
143
+ mock_jm.get_job.return_value = mock_job_with_files
144
+ mock_jm_class.return_value = mock_jm
145
+
146
+ mock_storage = Mock()
147
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
148
+ mock_storage_class.return_value = mock_storage
149
+
150
+ response = client.get("/api/admin/jobs/test-job-123/files")
151
+
152
+ data = response.json()
153
+ # Should have: input, stems.instrumental_clean, stems.vocals,
154
+ # lyrics.corrections, lyrics.lrc, finals.lossy_720p_mp4
155
+ # NOT youtube.url or youtube.video_id (not GCS paths)
156
+ assert data["total_files"] == 6
157
+
158
+ def test_handles_nested_file_structure(self, client, mock_job_with_files):
159
+ """Test that nested file_urls structure is properly traversed."""
160
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
161
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
162
+
163
+ mock_jm = Mock()
164
+ mock_jm.get_job.return_value = mock_job_with_files
165
+ mock_jm_class.return_value = mock_jm
166
+
167
+ mock_storage = Mock()
168
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
169
+ mock_storage_class.return_value = mock_storage
170
+
171
+ response = client.get("/api/admin/jobs/test-job-123/files")
172
+
173
+ data = response.json()
174
+ files = data["files"]
175
+
176
+ # Check that files from different categories are present
177
+ categories = {f["category"] for f in files}
178
+ assert "input" in categories or any(f["category"] == "" and f["file_key"] == "input" for f in files)
179
+ assert "stems" in categories
180
+ assert "lyrics" in categories
181
+ assert "finals" in categories
182
+
183
+ def test_file_info_structure(self, client, mock_job_with_files):
184
+ """Test that each file has correct info structure."""
185
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
186
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
187
+
188
+ mock_jm = Mock()
189
+ mock_jm.get_job.return_value = mock_job_with_files
190
+ mock_jm_class.return_value = mock_jm
191
+
192
+ mock_storage = Mock()
193
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
194
+ mock_storage_class.return_value = mock_storage
195
+
196
+ response = client.get("/api/admin/jobs/test-job-123/files")
197
+
198
+ data = response.json()
199
+ files = data["files"]
200
+
201
+ # Every file should have these fields
202
+ for file_info in files:
203
+ assert "name" in file_info
204
+ assert "path" in file_info
205
+ assert "download_url" in file_info
206
+ assert "category" in file_info
207
+ assert "file_key" in file_info
208
+ # download_url should be a signed URL
209
+ assert file_info["download_url"].startswith("https://signed.url/")
210
+
211
+ def test_returns_404_when_job_not_found(self, client):
212
+ """Test 404 when job doesn't exist."""
213
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
214
+ mock_jm = Mock()
215
+ mock_jm.get_job.return_value = None
216
+ mock_jm_class.return_value = mock_jm
217
+
218
+ response = client.get(
219
+ "/api/admin/jobs/nonexistent-job/files",
220
+ headers={"Authorization": "Bearer admin-token"}
221
+ )
222
+
223
+ assert response.status_code == 404
224
+ assert "not found" in response.json()["detail"].lower()
225
+
226
+ def test_returns_empty_files_for_new_job(self, client, mock_job_no_files):
227
+ """Test that new jobs with no files return empty list."""
228
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
229
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
230
+
231
+ mock_jm = Mock()
232
+ mock_jm.get_job.return_value = mock_job_no_files
233
+ mock_jm_class.return_value = mock_jm
234
+
235
+ mock_storage = Mock()
236
+ mock_storage_class.return_value = mock_storage
237
+
238
+ response = client.get("/api/admin/jobs/test-job-empty/files")
239
+
240
+ assert response.status_code == 200
241
+ data = response.json()
242
+ assert data["job_id"] == "test-job-empty"
243
+ assert data["files"] == []
244
+ assert data["total_files"] == 0
245
+
246
+ def test_handles_partial_processing(self, client, mock_job_partial_files):
247
+ """Test job with only some files processed."""
248
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
249
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
250
+
251
+ mock_jm = Mock()
252
+ mock_jm.get_job.return_value = mock_job_partial_files
253
+ mock_jm_class.return_value = mock_jm
254
+
255
+ mock_storage = Mock()
256
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
257
+ mock_storage_class.return_value = mock_storage
258
+
259
+ response = client.get("/api/admin/jobs/test-job-partial/files")
260
+
261
+ assert response.status_code == 200
262
+ data = response.json()
263
+ # Should have input + stems.instrumental_clean = 2 files
264
+ assert data["total_files"] == 2
265
+
266
+ def test_skips_non_gcs_urls(self, client, mock_job_with_files):
267
+ """Test that non-GCS URLs (like youtube URLs) are skipped."""
268
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
269
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
270
+
271
+ mock_jm = Mock()
272
+ mock_jm.get_job.return_value = mock_job_with_files
273
+ mock_jm_class.return_value = mock_jm
274
+
275
+ mock_storage = Mock()
276
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
277
+ mock_storage_class.return_value = mock_storage
278
+
279
+ response = client.get("/api/admin/jobs/test-job-123/files")
280
+
281
+ data = response.json()
282
+ files = data["files"]
283
+
284
+ # Ensure no youtube URLs or video IDs are in the files
285
+ for file_info in files:
286
+ assert "youtube.com" not in file_info["path"]
287
+ assert file_info["path"].startswith("gs://")
288
+
289
+ def test_signed_url_expiration(self, client, mock_job_with_files):
290
+ """Test that signed URLs are generated with appropriate expiration."""
291
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
292
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
293
+
294
+ mock_jm = Mock()
295
+ mock_jm.get_job.return_value = mock_job_with_files
296
+ mock_jm_class.return_value = mock_jm
297
+
298
+ mock_storage = Mock()
299
+ mock_storage.generate_signed_url.return_value = "https://signed.url/test"
300
+ mock_storage_class.return_value = mock_storage
301
+
302
+ client.get("/api/admin/jobs/test-job-123/files")
303
+
304
+ # Verify signed URLs were requested with expiration (default 120 minutes)
305
+ assert mock_storage.generate_signed_url.called
306
+ # Check that expiration_minutes was passed (could be any reasonable value)
307
+ call_kwargs = mock_storage.generate_signed_url.call_args_list[0][1]
308
+ if "expiration_minutes" in call_kwargs:
309
+ assert call_kwargs["expiration_minutes"] >= 60
310
+
311
+
312
+ class TestGetJobFilesAuthorization:
313
+ """Tests for authorization on the files endpoint."""
314
+
315
+ def test_requires_admin_access(self, client, mock_job_with_files):
316
+ """Test that non-admin users cannot access the endpoint."""
317
+ # Reset the dependency override to test auth
318
+ original_override = app.dependency_overrides.get(require_admin)
319
+
320
+ def get_non_admin():
321
+ from fastapi import HTTPException
322
+ raise HTTPException(status_code=403, detail="Admin access required")
323
+
324
+ app.dependency_overrides[require_admin] = get_non_admin
325
+
326
+ try:
327
+ response = client.get(
328
+ "/api/admin/jobs/test-job-123/files",
329
+ headers={"Authorization": "Bearer user-token"}
330
+ )
331
+ assert response.status_code == 403
332
+ finally:
333
+ # Restore the original override
334
+ if original_override:
335
+ app.dependency_overrides[require_admin] = original_override
336
+ else:
337
+ app.dependency_overrides[require_admin] = get_mock_admin