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.
- backend/api/routes/admin.py +512 -1
- backend/api/routes/audio_search.py +13 -2
- backend/api/routes/file_upload.py +42 -1
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +9 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +167 -245
- backend/main.py +6 -1
- 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/firestore_service.py +6 -0
- backend/services/job_manager.py +32 -1
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- 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 +146 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -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/workers/video_worker.py +8 -3
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +42 -28
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|