karaoke-gen 0.103.1__py3-none-any.whl → 0.105.4__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/Dockerfile.base +1 -0
- backend/api/routes/admin.py +226 -3
- backend/api/routes/push.py +238 -0
- backend/config.py +9 -1
- backend/main.py +2 -1
- backend/models/job.py +4 -0
- backend/models/user.py +20 -2
- backend/services/gce_encoding/main.py +22 -8
- backend/services/job_manager.py +68 -11
- backend/services/push_notification_service.py +409 -0
- backend/services/stripe_service.py +2 -2
- backend/tests/conftest.py +2 -1
- backend/tests/test_admin_delete_outputs.py +352 -0
- backend/tests/test_gce_encoding_worker.py +229 -0
- backend/tests/test_impersonation.py +18 -3
- backend/tests/test_push_notification_service.py +460 -0
- backend/tests/test_push_routes.py +357 -0
- backend/tests/test_stripe_service.py +205 -0
- backend/workers/video_worker_orchestrator.py +16 -0
- karaoke_gen/instrumental_review/static/index.html +35 -9
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +25 -18
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for admin delete job outputs endpoint.
|
|
3
|
+
|
|
4
|
+
Tests the POST /api/admin/jobs/{job_id}/delete-outputs endpoint that allows admins
|
|
5
|
+
to delete distributed outputs (YouTube, Dropbox, Google Drive) while preserving
|
|
6
|
+
the job record.
|
|
7
|
+
"""
|
|
8
|
+
import pytest
|
|
9
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from backend.api.routes.admin import router
|
|
15
|
+
from backend.api.dependencies import require_admin
|
|
16
|
+
from backend.models.job import Job, JobStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Create a test app with the admin router
|
|
20
|
+
app = FastAPI()
|
|
21
|
+
app.include_router(router, prefix="/api")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_mock_admin():
|
|
25
|
+
"""Override for require_admin dependency."""
|
|
26
|
+
from backend.api.dependencies import AuthResult, UserType
|
|
27
|
+
return AuthResult(
|
|
28
|
+
is_valid=True,
|
|
29
|
+
user_type=UserType.ADMIN,
|
|
30
|
+
remaining_uses=999,
|
|
31
|
+
message="Admin authenticated",
|
|
32
|
+
user_email="admin@example.com",
|
|
33
|
+
is_admin=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Override the require_admin dependency
|
|
38
|
+
app.dependency_overrides[require_admin] = get_mock_admin
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def client():
|
|
43
|
+
"""Create a test client."""
|
|
44
|
+
return TestClient(app)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def mock_complete_job():
|
|
49
|
+
"""Create a mock job in COMPLETE status with distribution data."""
|
|
50
|
+
job = Mock(spec=Job)
|
|
51
|
+
job.job_id = "test-job-123"
|
|
52
|
+
job.user_email = "user@example.com"
|
|
53
|
+
job.artist = "Test Artist"
|
|
54
|
+
job.title = "Test Title"
|
|
55
|
+
job.status = "complete"
|
|
56
|
+
job.dropbox_path = "/Karaoke/Organized"
|
|
57
|
+
job.outputs_deleted_at = None
|
|
58
|
+
job.outputs_deleted_by = None
|
|
59
|
+
job.state_data = {
|
|
60
|
+
"youtube_url": "https://youtu.be/abc123",
|
|
61
|
+
"brand_code": "NOMAD-1234",
|
|
62
|
+
"dropbox_link": "https://dropbox.com/...",
|
|
63
|
+
"gdrive_files": {"mp4": "file_id_1", "mp4_720p": "file_id_2"},
|
|
64
|
+
}
|
|
65
|
+
job.timeline = []
|
|
66
|
+
return job
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture
|
|
70
|
+
def mock_job_no_outputs():
|
|
71
|
+
"""Create a mock job in COMPLETE status without distribution data."""
|
|
72
|
+
job = Mock(spec=Job)
|
|
73
|
+
job.job_id = "test-job-456"
|
|
74
|
+
job.user_email = "user@example.com"
|
|
75
|
+
job.artist = "Test Artist"
|
|
76
|
+
job.title = "Test Title"
|
|
77
|
+
job.status = "complete"
|
|
78
|
+
job.dropbox_path = None
|
|
79
|
+
job.outputs_deleted_at = None
|
|
80
|
+
job.outputs_deleted_by = None
|
|
81
|
+
job.state_data = {}
|
|
82
|
+
job.timeline = []
|
|
83
|
+
return job
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestDeleteOutputsSuccess:
|
|
87
|
+
"""Tests for successful output deletion."""
|
|
88
|
+
|
|
89
|
+
def test_delete_outputs_success(self, client, mock_complete_job):
|
|
90
|
+
"""Test successfully deleting outputs from a complete job."""
|
|
91
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
92
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service:
|
|
93
|
+
mock_jm = Mock()
|
|
94
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
95
|
+
mock_jm_class.return_value = mock_jm
|
|
96
|
+
|
|
97
|
+
# Mock Firestore
|
|
98
|
+
mock_db = Mock()
|
|
99
|
+
mock_job_ref = Mock()
|
|
100
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
101
|
+
mock_user_service.return_value.db = mock_db
|
|
102
|
+
|
|
103
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
104
|
+
|
|
105
|
+
assert response.status_code == 200
|
|
106
|
+
data = response.json()
|
|
107
|
+
assert data["status"] == "success"
|
|
108
|
+
assert data["job_id"] == "test-job-123"
|
|
109
|
+
assert "outputs_deleted_at" in data
|
|
110
|
+
|
|
111
|
+
def test_delete_outputs_clears_state_data_keys(self, client, mock_complete_job):
|
|
112
|
+
"""Test that output-related state_data keys are listed as cleared."""
|
|
113
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
114
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service:
|
|
115
|
+
mock_jm = Mock()
|
|
116
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
117
|
+
mock_jm_class.return_value = mock_jm
|
|
118
|
+
|
|
119
|
+
# Mock Firestore
|
|
120
|
+
mock_db = Mock()
|
|
121
|
+
mock_job_ref = Mock()
|
|
122
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
123
|
+
mock_user_service.return_value.db = mock_db
|
|
124
|
+
|
|
125
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
126
|
+
|
|
127
|
+
assert response.status_code == 200
|
|
128
|
+
data = response.json()
|
|
129
|
+
# Should list cleared keys
|
|
130
|
+
assert "youtube_url" in data["cleared_state_data"]
|
|
131
|
+
assert "brand_code" in data["cleared_state_data"]
|
|
132
|
+
assert "dropbox_link" in data["cleared_state_data"]
|
|
133
|
+
assert "gdrive_files" in data["cleared_state_data"]
|
|
134
|
+
|
|
135
|
+
def test_delete_outputs_job_without_distribution(self, client, mock_job_no_outputs):
|
|
136
|
+
"""Test deleting outputs from job that has no distribution data."""
|
|
137
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
138
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service:
|
|
139
|
+
mock_jm = Mock()
|
|
140
|
+
mock_jm.get_job.return_value = mock_job_no_outputs
|
|
141
|
+
mock_jm_class.return_value = mock_jm
|
|
142
|
+
|
|
143
|
+
# Mock Firestore
|
|
144
|
+
mock_db = Mock()
|
|
145
|
+
mock_job_ref = Mock()
|
|
146
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
147
|
+
mock_user_service.return_value.db = mock_db
|
|
148
|
+
|
|
149
|
+
response = client.post("/api/admin/jobs/test-job-456/delete-outputs")
|
|
150
|
+
|
|
151
|
+
assert response.status_code == 200
|
|
152
|
+
data = response.json()
|
|
153
|
+
assert data["status"] == "success"
|
|
154
|
+
# All services should be skipped
|
|
155
|
+
assert data["deleted_services"]["youtube"]["status"] == "skipped"
|
|
156
|
+
assert data["deleted_services"]["dropbox"]["status"] == "skipped"
|
|
157
|
+
assert data["deleted_services"]["gdrive"]["status"] == "skipped"
|
|
158
|
+
# No keys to clear
|
|
159
|
+
assert data["cleared_state_data"] == []
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestDeleteOutputsValidation:
|
|
163
|
+
"""Tests for validation on delete outputs endpoint."""
|
|
164
|
+
|
|
165
|
+
def test_rejects_non_terminal_status(self, client, mock_complete_job):
|
|
166
|
+
"""Test that jobs not in terminal states are rejected."""
|
|
167
|
+
mock_complete_job.status = "encoding"
|
|
168
|
+
|
|
169
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
|
|
170
|
+
mock_jm = Mock()
|
|
171
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
172
|
+
mock_jm_class.return_value = mock_jm
|
|
173
|
+
|
|
174
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
175
|
+
|
|
176
|
+
assert response.status_code == 400
|
|
177
|
+
assert "terminal" in response.json()["detail"].lower()
|
|
178
|
+
|
|
179
|
+
def test_rejects_awaiting_review_status(self, client, mock_complete_job):
|
|
180
|
+
"""Test that AWAITING_REVIEW is rejected (not terminal)."""
|
|
181
|
+
mock_complete_job.status = "awaiting_review"
|
|
182
|
+
|
|
183
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
|
|
184
|
+
mock_jm = Mock()
|
|
185
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
186
|
+
mock_jm_class.return_value = mock_jm
|
|
187
|
+
|
|
188
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
189
|
+
|
|
190
|
+
assert response.status_code == 400
|
|
191
|
+
|
|
192
|
+
def test_accepts_prep_complete_status(self, client, mock_complete_job):
|
|
193
|
+
"""Test that prep_complete is accepted as terminal."""
|
|
194
|
+
mock_complete_job.status = "prep_complete"
|
|
195
|
+
|
|
196
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
197
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service:
|
|
198
|
+
mock_jm = Mock()
|
|
199
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
200
|
+
mock_jm_class.return_value = mock_jm
|
|
201
|
+
|
|
202
|
+
mock_db = Mock()
|
|
203
|
+
mock_job_ref = Mock()
|
|
204
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
205
|
+
mock_user_service.return_value.db = mock_db
|
|
206
|
+
|
|
207
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
208
|
+
|
|
209
|
+
assert response.status_code == 200
|
|
210
|
+
|
|
211
|
+
def test_accepts_failed_status(self, client, mock_complete_job):
|
|
212
|
+
"""Test that failed is accepted as terminal."""
|
|
213
|
+
mock_complete_job.status = "failed"
|
|
214
|
+
|
|
215
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
216
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service:
|
|
217
|
+
mock_jm = Mock()
|
|
218
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
219
|
+
mock_jm_class.return_value = mock_jm
|
|
220
|
+
|
|
221
|
+
mock_db = Mock()
|
|
222
|
+
mock_job_ref = Mock()
|
|
223
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
224
|
+
mock_user_service.return_value.db = mock_db
|
|
225
|
+
|
|
226
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
227
|
+
|
|
228
|
+
assert response.status_code == 200
|
|
229
|
+
|
|
230
|
+
def test_rejects_already_deleted(self, client, mock_complete_job):
|
|
231
|
+
"""Test that already-deleted outputs cannot be deleted again."""
|
|
232
|
+
mock_complete_job.outputs_deleted_at = datetime(2026, 1, 9, 12, 0, 0)
|
|
233
|
+
|
|
234
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
|
|
235
|
+
mock_jm = Mock()
|
|
236
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
237
|
+
mock_jm_class.return_value = mock_jm
|
|
238
|
+
|
|
239
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
240
|
+
|
|
241
|
+
assert response.status_code == 400
|
|
242
|
+
assert "already deleted" in response.json()["detail"].lower()
|
|
243
|
+
|
|
244
|
+
def test_returns_404_for_missing_job(self, client):
|
|
245
|
+
"""Test 404 when job doesn't exist."""
|
|
246
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
|
|
247
|
+
mock_jm = Mock()
|
|
248
|
+
mock_jm.get_job.return_value = None
|
|
249
|
+
mock_jm_class.return_value = mock_jm
|
|
250
|
+
|
|
251
|
+
response = client.post("/api/admin/jobs/nonexistent/delete-outputs")
|
|
252
|
+
|
|
253
|
+
assert response.status_code == 404
|
|
254
|
+
assert "not found" in response.json()["detail"].lower()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class TestDeleteOutputsServices:
|
|
258
|
+
"""Tests for service deletion behavior."""
|
|
259
|
+
|
|
260
|
+
def test_handles_missing_youtube(self, client, mock_complete_job):
|
|
261
|
+
"""Test handling when job has no YouTube URL."""
|
|
262
|
+
mock_complete_job.state_data = {"brand_code": "NOMAD-1234"}
|
|
263
|
+
|
|
264
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
265
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service:
|
|
266
|
+
mock_jm = Mock()
|
|
267
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
268
|
+
mock_jm_class.return_value = mock_jm
|
|
269
|
+
|
|
270
|
+
mock_db = Mock()
|
|
271
|
+
mock_job_ref = Mock()
|
|
272
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
273
|
+
mock_user_service.return_value.db = mock_db
|
|
274
|
+
|
|
275
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
276
|
+
|
|
277
|
+
assert response.status_code == 200
|
|
278
|
+
data = response.json()
|
|
279
|
+
assert data["deleted_services"]["youtube"]["status"] == "skipped"
|
|
280
|
+
|
|
281
|
+
def test_handles_missing_dropbox_path(self, client, mock_complete_job):
|
|
282
|
+
"""Test handling when job has no dropbox_path."""
|
|
283
|
+
mock_complete_job.dropbox_path = None
|
|
284
|
+
mock_complete_job.state_data = {"youtube_url": "https://youtu.be/abc123"}
|
|
285
|
+
|
|
286
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
287
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service:
|
|
288
|
+
mock_jm = Mock()
|
|
289
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
290
|
+
mock_jm_class.return_value = mock_jm
|
|
291
|
+
|
|
292
|
+
mock_db = Mock()
|
|
293
|
+
mock_job_ref = Mock()
|
|
294
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
295
|
+
mock_user_service.return_value.db = mock_db
|
|
296
|
+
|
|
297
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
298
|
+
|
|
299
|
+
assert response.status_code == 200
|
|
300
|
+
data = response.json()
|
|
301
|
+
assert data["deleted_services"]["dropbox"]["status"] == "skipped"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class TestDeleteOutputsLogging:
|
|
305
|
+
"""Tests for logging on delete outputs endpoint."""
|
|
306
|
+
|
|
307
|
+
def test_logs_admin_action(self, client, mock_complete_job):
|
|
308
|
+
"""Test that admin delete action is logged."""
|
|
309
|
+
with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
|
|
310
|
+
patch('backend.api.routes.admin.get_user_service') as mock_user_service, \
|
|
311
|
+
patch('backend.api.routes.admin.logger') as mock_logger:
|
|
312
|
+
mock_jm = Mock()
|
|
313
|
+
mock_jm.get_job.return_value = mock_complete_job
|
|
314
|
+
mock_jm_class.return_value = mock_jm
|
|
315
|
+
|
|
316
|
+
mock_db = Mock()
|
|
317
|
+
mock_job_ref = Mock()
|
|
318
|
+
mock_db.collection.return_value.document.return_value = mock_job_ref
|
|
319
|
+
mock_user_service.return_value.db = mock_db
|
|
320
|
+
|
|
321
|
+
response = client.post("/api/admin/jobs/test-job-123/delete-outputs")
|
|
322
|
+
|
|
323
|
+
assert response.status_code == 200
|
|
324
|
+
mock_logger.info.assert_called()
|
|
325
|
+
log_message = mock_logger.info.call_args[0][0]
|
|
326
|
+
assert "admin" in log_message.lower() or "deleted" in log_message.lower()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class TestDeleteOutputsAuthorization:
|
|
330
|
+
"""Tests for authorization on the delete outputs endpoint."""
|
|
331
|
+
|
|
332
|
+
def test_requires_admin_access(self, client, mock_complete_job):
|
|
333
|
+
"""Test that non-admin users cannot access the endpoint."""
|
|
334
|
+
original_override = app.dependency_overrides.get(require_admin)
|
|
335
|
+
|
|
336
|
+
def get_non_admin():
|
|
337
|
+
from fastapi import HTTPException
|
|
338
|
+
raise HTTPException(status_code=403, detail="Admin access required")
|
|
339
|
+
|
|
340
|
+
app.dependency_overrides[require_admin] = get_non_admin
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
response = client.post(
|
|
344
|
+
"/api/admin/jobs/test-job-123/delete-outputs",
|
|
345
|
+
headers={"Authorization": "Bearer user-token"}
|
|
346
|
+
)
|
|
347
|
+
assert response.status_code == 403
|
|
348
|
+
finally:
|
|
349
|
+
if original_override:
|
|
350
|
+
app.dependency_overrides[require_admin] = original_override
|
|
351
|
+
else:
|
|
352
|
+
app.dependency_overrides[require_admin] = get_mock_admin
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for GCE Encoding Worker (gce_encoding/main.py).
|
|
3
|
+
|
|
4
|
+
These tests verify the file-finding logic in the GCE encoding worker,
|
|
5
|
+
particularly ensuring that instrumental selection is respected.
|
|
6
|
+
|
|
7
|
+
NOTE: This test file is self-contained and doesn't import the full app
|
|
8
|
+
to avoid dependency issues in CI/CD environments.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Copy the find_file function here to test it in isolation
|
|
16
|
+
# This avoids importing the full gce_encoding module which has GCS dependencies
|
|
17
|
+
def find_file(work_dir: Path, *patterns):
|
|
18
|
+
'''Find a file matching any of the given glob patterns.'''
|
|
19
|
+
for pattern in patterns:
|
|
20
|
+
matches = list(work_dir.glob(f"**/{pattern}"))
|
|
21
|
+
if matches:
|
|
22
|
+
return matches[0]
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestFindFile:
|
|
27
|
+
"""Test the find_file function used to locate input files."""
|
|
28
|
+
|
|
29
|
+
def test_find_file_single_match(self, tmp_path):
|
|
30
|
+
"""Test finding a file with a single match."""
|
|
31
|
+
# Create test file
|
|
32
|
+
test_file = tmp_path / "test_instrumental.flac"
|
|
33
|
+
test_file.touch()
|
|
34
|
+
|
|
35
|
+
result = find_file(tmp_path, "*instrumental*.flac")
|
|
36
|
+
assert result is not None
|
|
37
|
+
assert result.name == "test_instrumental.flac"
|
|
38
|
+
|
|
39
|
+
def test_find_file_no_match(self, tmp_path):
|
|
40
|
+
"""Test finding a file with no matches returns None."""
|
|
41
|
+
result = find_file(tmp_path, "*nonexistent*.flac")
|
|
42
|
+
assert result is None
|
|
43
|
+
|
|
44
|
+
def test_find_file_priority_order(self, tmp_path):
|
|
45
|
+
"""Test that find_file returns first matching pattern."""
|
|
46
|
+
# Create multiple files that match different patterns
|
|
47
|
+
first = tmp_path / "first_pattern.flac"
|
|
48
|
+
second = tmp_path / "second_pattern.flac"
|
|
49
|
+
first.touch()
|
|
50
|
+
second.touch()
|
|
51
|
+
|
|
52
|
+
# First pattern should win
|
|
53
|
+
result = find_file(tmp_path, "*first*.flac", "*second*.flac")
|
|
54
|
+
assert result.name == "first_pattern.flac"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestInstrumentalSelection:
|
|
58
|
+
"""Test that instrumental selection is properly respected."""
|
|
59
|
+
|
|
60
|
+
def test_clean_instrumental_prioritized_when_selected(self, tmp_path):
|
|
61
|
+
"""Test that clean instrumental is found when 'clean' is selected.
|
|
62
|
+
|
|
63
|
+
When both instrumentals exist and 'clean' is selected,
|
|
64
|
+
the clean version should be found.
|
|
65
|
+
"""
|
|
66
|
+
# Create both instrumental files (as they exist in GCS)
|
|
67
|
+
clean = tmp_path / "Artist - Title (Instrumental Clean).flac"
|
|
68
|
+
backing = tmp_path / "Artist - Title (Instrumental Backing).flac"
|
|
69
|
+
clean.touch()
|
|
70
|
+
backing.touch()
|
|
71
|
+
|
|
72
|
+
# Simulate finding instrumental with 'clean' selection
|
|
73
|
+
# (mimics the logic in run_encoding when instrumental_selection == 'clean')
|
|
74
|
+
result = find_file(
|
|
75
|
+
tmp_path,
|
|
76
|
+
"*instrumental_clean*.flac", "*Instrumental Clean*.flac",
|
|
77
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
78
|
+
"*instrumental*.wav"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert result is not None
|
|
82
|
+
assert "Clean" in result.name
|
|
83
|
+
|
|
84
|
+
def test_backing_instrumental_prioritized_when_selected(self, tmp_path):
|
|
85
|
+
"""Test that backing instrumental is found when 'with_backing' is selected.
|
|
86
|
+
|
|
87
|
+
When both instrumentals exist and 'with_backing' is selected,
|
|
88
|
+
the backing version should be found.
|
|
89
|
+
|
|
90
|
+
This test would have caught the bug where the GCE worker always
|
|
91
|
+
used the clean instrumental regardless of user selection.
|
|
92
|
+
"""
|
|
93
|
+
# Create both instrumental files (as they exist in GCS)
|
|
94
|
+
clean = tmp_path / "Artist - Title (Instrumental Clean).flac"
|
|
95
|
+
backing = tmp_path / "Artist - Title (Instrumental Backing).flac"
|
|
96
|
+
clean.touch()
|
|
97
|
+
backing.touch()
|
|
98
|
+
|
|
99
|
+
# Simulate finding instrumental with 'with_backing' selection
|
|
100
|
+
# (mimics the logic in run_encoding when instrumental_selection == 'with_backing')
|
|
101
|
+
result = find_file(
|
|
102
|
+
tmp_path,
|
|
103
|
+
"*instrumental_with_backing*.flac", "*Instrumental Backing*.flac",
|
|
104
|
+
"*with_backing*.flac", "*Backing*.flac",
|
|
105
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
106
|
+
"*instrumental*.wav"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
assert result is not None
|
|
110
|
+
assert "Backing" in result.name
|
|
111
|
+
|
|
112
|
+
def test_backing_instrumental_with_stem_naming(self, tmp_path):
|
|
113
|
+
"""Test finding backing instrumental with GCS stem naming convention.
|
|
114
|
+
|
|
115
|
+
In GCS, stems are named like 'stems/instrumental_with_backing.flac'
|
|
116
|
+
"""
|
|
117
|
+
stems_dir = tmp_path / "stems"
|
|
118
|
+
stems_dir.mkdir()
|
|
119
|
+
|
|
120
|
+
clean = stems_dir / "instrumental_clean.flac"
|
|
121
|
+
backing = stems_dir / "instrumental_with_backing.flac"
|
|
122
|
+
clean.touch()
|
|
123
|
+
backing.touch()
|
|
124
|
+
|
|
125
|
+
# Simulate finding instrumental with 'with_backing' selection
|
|
126
|
+
result = find_file(
|
|
127
|
+
tmp_path,
|
|
128
|
+
"*instrumental_with_backing*.flac", "*Instrumental Backing*.flac",
|
|
129
|
+
"*with_backing*.flac", "*Backing*.flac",
|
|
130
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
131
|
+
"*instrumental*.wav"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
assert result is not None
|
|
135
|
+
assert "with_backing" in result.name
|
|
136
|
+
|
|
137
|
+
def test_fallback_to_generic_when_specific_not_found(self, tmp_path):
|
|
138
|
+
"""Test fallback to generic pattern when specific not found."""
|
|
139
|
+
# Only create a generically named instrumental
|
|
140
|
+
generic = tmp_path / "instrumental.flac"
|
|
141
|
+
generic.touch()
|
|
142
|
+
|
|
143
|
+
# Both clean and backing selections should fall back
|
|
144
|
+
for selection in ["clean", "with_backing"]:
|
|
145
|
+
if selection == "with_backing":
|
|
146
|
+
patterns = (
|
|
147
|
+
"*instrumental_with_backing*.flac", "*Instrumental Backing*.flac",
|
|
148
|
+
"*with_backing*.flac", "*Backing*.flac",
|
|
149
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
150
|
+
"*instrumental*.wav"
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
patterns = (
|
|
154
|
+
"*instrumental_clean*.flac", "*Instrumental Clean*.flac",
|
|
155
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
156
|
+
"*instrumental*.wav"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
result = find_file(tmp_path, *patterns)
|
|
160
|
+
assert result is not None
|
|
161
|
+
assert result.name == "instrumental.flac"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestInstrumentalSelectionRegression:
|
|
165
|
+
"""Regression tests for instrumental selection bug.
|
|
166
|
+
|
|
167
|
+
Bug: User selected 'with_backing' instrumental in the UI but the final
|
|
168
|
+
video used the clean instrumental instead.
|
|
169
|
+
|
|
170
|
+
Root cause: The GCE worker's find_file() always prioritized
|
|
171
|
+
'*instrumental_clean*' patterns before generic patterns, ignoring
|
|
172
|
+
the user's selection.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def test_bug_scenario_both_instrumentals_present(self, tmp_path):
|
|
176
|
+
"""Recreate the exact bug scenario where both instrumentals exist.
|
|
177
|
+
|
|
178
|
+
This is the key regression test. When both instrumentals are downloaded
|
|
179
|
+
from GCS (which happens with the GCE worker), the correct one must
|
|
180
|
+
be selected based on the user's choice.
|
|
181
|
+
"""
|
|
182
|
+
# Setup: Both instrumentals in stems/ (as downloaded from GCS)
|
|
183
|
+
stems = tmp_path / "stems"
|
|
184
|
+
stems.mkdir()
|
|
185
|
+
|
|
186
|
+
(stems / "instrumental_clean.flac").touch()
|
|
187
|
+
(stems / "instrumental_with_backing.flac").touch()
|
|
188
|
+
|
|
189
|
+
# Also have properly named versions in the root (from _setup_working_directory)
|
|
190
|
+
(tmp_path / "Artist - Song (Instrumental Clean).flac").touch()
|
|
191
|
+
(tmp_path / "Artist - Song (Instrumental Backing).flac").touch()
|
|
192
|
+
|
|
193
|
+
# When user selected 'with_backing', the backing version MUST be found
|
|
194
|
+
backing_patterns = (
|
|
195
|
+
"*instrumental_with_backing*.flac", "*Instrumental Backing*.flac",
|
|
196
|
+
"*with_backing*.flac", "*Backing*.flac",
|
|
197
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
198
|
+
"*instrumental*.wav"
|
|
199
|
+
)
|
|
200
|
+
result = find_file(tmp_path, *backing_patterns)
|
|
201
|
+
|
|
202
|
+
assert result is not None
|
|
203
|
+
# Must find a backing-related file, not the clean one
|
|
204
|
+
name = result.name.lower()
|
|
205
|
+
assert "backing" in name or "with_backing" in name, \
|
|
206
|
+
f"Expected backing instrumental but found: {result.name}"
|
|
207
|
+
|
|
208
|
+
def test_clean_selection_still_works(self, tmp_path):
|
|
209
|
+
"""Verify clean selection still works correctly after the fix."""
|
|
210
|
+
stems = tmp_path / "stems"
|
|
211
|
+
stems.mkdir()
|
|
212
|
+
|
|
213
|
+
(stems / "instrumental_clean.flac").touch()
|
|
214
|
+
(stems / "instrumental_with_backing.flac").touch()
|
|
215
|
+
|
|
216
|
+
(tmp_path / "Artist - Song (Instrumental Clean).flac").touch()
|
|
217
|
+
(tmp_path / "Artist - Song (Instrumental Backing).flac").touch()
|
|
218
|
+
|
|
219
|
+
# When user selected 'clean', the clean version must be found
|
|
220
|
+
clean_patterns = (
|
|
221
|
+
"*instrumental_clean*.flac", "*Instrumental Clean*.flac",
|
|
222
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
223
|
+
"*instrumental*.wav"
|
|
224
|
+
)
|
|
225
|
+
result = find_file(tmp_path, *clean_patterns)
|
|
226
|
+
|
|
227
|
+
assert result is not None
|
|
228
|
+
name = result.name.lower()
|
|
229
|
+
assert "clean" in name, f"Expected clean instrumental but found: {result.name}"
|
|
@@ -11,6 +11,7 @@ from fastapi import FastAPI
|
|
|
11
11
|
from backend.api.routes.admin import router
|
|
12
12
|
from backend.api.dependencies import require_admin
|
|
13
13
|
from backend.services.user_service import get_user_service
|
|
14
|
+
from backend.services.auth_service import AuthResult, UserType
|
|
14
15
|
from backend.models.user import User, Session
|
|
15
16
|
|
|
16
17
|
|
|
@@ -20,15 +21,29 @@ app.include_router(router, prefix="/api")
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def get_mock_admin():
|
|
23
|
-
"""Override for require_admin dependency - returns admin
|
|
24
|
-
return (
|
|
24
|
+
"""Override for require_admin dependency - returns admin AuthResult."""
|
|
25
|
+
return AuthResult(
|
|
26
|
+
is_valid=True,
|
|
27
|
+
user_type=UserType.ADMIN,
|
|
28
|
+
remaining_uses=-1,
|
|
29
|
+
message="Admin session valid",
|
|
30
|
+
user_email="admin@nomadkaraoke.com",
|
|
31
|
+
is_admin=True,
|
|
32
|
+
)
|
|
25
33
|
|
|
26
34
|
|
|
27
35
|
def get_mock_regular_user():
|
|
28
36
|
"""Override for require_admin dependency - returns regular user (should fail)."""
|
|
29
37
|
# This simulates what happens when a non-admin tries to access
|
|
30
38
|
# In reality, require_admin raises 403, but we test the logic
|
|
31
|
-
return (
|
|
39
|
+
return AuthResult(
|
|
40
|
+
is_valid=True,
|
|
41
|
+
user_type=UserType.LIMITED,
|
|
42
|
+
remaining_uses=5,
|
|
43
|
+
message="User session valid",
|
|
44
|
+
user_email="user@example.com",
|
|
45
|
+
is_admin=False,
|
|
46
|
+
)
|
|
32
47
|
|
|
33
48
|
|
|
34
49
|
@pytest.fixture
|