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
@@ -0,0 +1,384 @@
1
+ """
2
+ Unit tests for admin job reset endpoint.
3
+
4
+ Tests the POST /api/admin/jobs/{job_id}/reset endpoint that allows admins
5
+ to reset a job to a specific state for re-processing.
6
+ """
7
+ import pytest
8
+ from unittest.mock import Mock, patch
9
+ from fastapi.testclient import TestClient
10
+ from fastapi import FastAPI
11
+ from datetime import datetime
12
+
13
+ from backend.api.routes.admin import router
14
+ from backend.api.dependencies import require_admin
15
+ from backend.models.job import Job, JobStatus
16
+
17
+
18
+ # Create a test app with the admin router
19
+ app = FastAPI()
20
+ app.include_router(router, prefix="/api")
21
+
22
+
23
+ def get_mock_admin():
24
+ """Override for require_admin dependency."""
25
+ from backend.api.dependencies import AuthResult, UserType
26
+ return AuthResult(
27
+ is_valid=True,
28
+ user_type=UserType.ADMIN,
29
+ remaining_uses=999,
30
+ message="Admin authenticated",
31
+ user_email="admin@example.com",
32
+ is_admin=True,
33
+ )
34
+
35
+
36
+ # Override the require_admin dependency
37
+ app.dependency_overrides[require_admin] = get_mock_admin
38
+
39
+
40
+ @pytest.fixture
41
+ def client():
42
+ """Create a test client."""
43
+ return TestClient(app)
44
+
45
+
46
+ @pytest.fixture
47
+ def mock_job():
48
+ """Create a mock job in COMPLETE status for testing."""
49
+ job = Mock(spec=Job)
50
+ job.job_id = "test-job-123"
51
+ job.user_email = "user@example.com"
52
+ job.artist = "Test Artist"
53
+ job.title = "Test Title"
54
+ job.status = JobStatus.COMPLETE
55
+ job.theme_id = "nomad"
56
+ job.file_urls = {
57
+ "input": "gs://bucket/input.flac",
58
+ "stems": {
59
+ "instrumental_clean": "gs://bucket/stems/clean.flac",
60
+ },
61
+ "lyrics": {
62
+ "corrections": "gs://bucket/lyrics/corrections.json",
63
+ },
64
+ }
65
+ job.state_data = {
66
+ "audio_search_results": [{"provider": "test"}],
67
+ "audio_selection": {"index": 0},
68
+ "review_complete": True,
69
+ "corrected_lyrics": {"lines": []},
70
+ "instrumental_selection": "clean",
71
+ "brand_code": "NOMAD-1234",
72
+ }
73
+ job.timeline = []
74
+ job.created_at = datetime(2026, 1, 9, 10, 0, 0)
75
+ job.updated_at = datetime(2026, 1, 9, 12, 0, 0)
76
+ return job
77
+
78
+
79
+ class TestResetJobToPending:
80
+ """Tests for resetting job to PENDING state."""
81
+
82
+ def test_reset_to_pending_success(self, client, mock_job):
83
+ """Test resetting a job to PENDING state."""
84
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
85
+ mock_jm = Mock()
86
+ mock_jm.get_job.return_value = mock_job
87
+ mock_jm.update_job.return_value = True
88
+ mock_jm_class.return_value = mock_jm
89
+
90
+ response = client.post(
91
+ "/api/admin/jobs/test-job-123/reset",
92
+ json={"target_state": "pending"},
93
+ )
94
+
95
+ assert response.status_code == 200
96
+ data = response.json()
97
+ assert data["status"] == "success"
98
+ assert data["job_id"] == "test-job-123"
99
+ assert data["new_status"] == "pending"
100
+ assert "pending" in data["message"].lower()
101
+
102
+ def test_reset_to_pending_clears_state_data(self, client, mock_job):
103
+ """Test that resetting to PENDING clears appropriate state_data keys."""
104
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
105
+ mock_jm = Mock()
106
+ mock_jm.get_job.return_value = mock_job
107
+ mock_jm.update_job.return_value = True
108
+ mock_jm_class.return_value = mock_jm
109
+
110
+ response = client.post(
111
+ "/api/admin/jobs/test-job-123/reset",
112
+ json={"target_state": "pending"},
113
+ )
114
+
115
+ assert response.status_code == 200
116
+ # Verify update_job was called with cleared state_data keys
117
+ call_args = mock_jm.update_job.call_args
118
+ update_data = call_args[0][1]
119
+ # Verify status was set
120
+ assert update_data["status"] == "pending"
121
+
122
+
123
+ class TestResetJobToAwaitingAudioSelection:
124
+ """Tests for resetting job to AWAITING_AUDIO_SELECTION state."""
125
+
126
+ def test_reset_to_awaiting_audio_selection(self, client, mock_job):
127
+ """Test resetting a job to AWAITING_AUDIO_SELECTION state."""
128
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
129
+ mock_jm = Mock()
130
+ mock_jm.get_job.return_value = mock_job
131
+ mock_jm.update_job.return_value = True
132
+ mock_jm_class.return_value = mock_jm
133
+
134
+ response = client.post(
135
+ "/api/admin/jobs/test-job-123/reset",
136
+ json={"target_state": "awaiting_audio_selection"},
137
+ )
138
+
139
+ assert response.status_code == 200
140
+ data = response.json()
141
+ assert data["new_status"] == "awaiting_audio_selection"
142
+
143
+
144
+ class TestResetJobToAwaitingReview:
145
+ """Tests for resetting job to AWAITING_REVIEW state."""
146
+
147
+ def test_reset_to_awaiting_review(self, client, mock_job):
148
+ """Test resetting a job to AWAITING_REVIEW state."""
149
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
150
+ mock_jm = Mock()
151
+ mock_jm.get_job.return_value = mock_job
152
+ mock_jm.update_job.return_value = True
153
+ mock_jm_class.return_value = mock_jm
154
+
155
+ response = client.post(
156
+ "/api/admin/jobs/test-job-123/reset",
157
+ json={"target_state": "awaiting_review"},
158
+ )
159
+
160
+ assert response.status_code == 200
161
+ data = response.json()
162
+ assert data["new_status"] == "awaiting_review"
163
+
164
+
165
+ class TestResetJobToAwaitingInstrumentalSelection:
166
+ """Tests for resetting job to AWAITING_INSTRUMENTAL_SELECTION state."""
167
+
168
+ def test_reset_to_awaiting_instrumental_selection(self, client, mock_job):
169
+ """Test resetting a job to AWAITING_INSTRUMENTAL_SELECTION state."""
170
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
171
+ mock_jm = Mock()
172
+ mock_jm.get_job.return_value = mock_job
173
+ mock_jm.update_job.return_value = True
174
+ mock_jm_class.return_value = mock_jm
175
+
176
+ response = client.post(
177
+ "/api/admin/jobs/test-job-123/reset",
178
+ json={"target_state": "awaiting_instrumental_selection"},
179
+ )
180
+
181
+ assert response.status_code == 200
182
+ data = response.json()
183
+ assert data["new_status"] == "awaiting_instrumental_selection"
184
+
185
+
186
+ class TestResetJobValidation:
187
+ """Tests for reset endpoint validation."""
188
+
189
+ def test_rejects_invalid_target_state(self, client, mock_job):
190
+ """Test that invalid target states are rejected."""
191
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
192
+ mock_jm = Mock()
193
+ mock_jm.get_job.return_value = mock_job
194
+ mock_jm_class.return_value = mock_jm
195
+
196
+ response = client.post(
197
+ "/api/admin/jobs/test-job-123/reset",
198
+ json={"target_state": "encoding"},
199
+ )
200
+
201
+ assert response.status_code == 400
202
+ assert "invalid" in response.json()["detail"].lower() or "not allowed" in response.json()["detail"].lower()
203
+
204
+ def test_rejects_complete_as_target(self, client, mock_job):
205
+ """Test that COMPLETE is not a valid reset target."""
206
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
207
+ mock_jm = Mock()
208
+ mock_jm.get_job.return_value = mock_job
209
+ mock_jm_class.return_value = mock_jm
210
+
211
+ response = client.post(
212
+ "/api/admin/jobs/test-job-123/reset",
213
+ json={"target_state": "complete"},
214
+ )
215
+
216
+ assert response.status_code == 400
217
+
218
+ def test_rejects_failed_as_target(self, client, mock_job):
219
+ """Test that FAILED is not a valid reset target."""
220
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
221
+ mock_jm = Mock()
222
+ mock_jm.get_job.return_value = mock_job
223
+ mock_jm_class.return_value = mock_jm
224
+
225
+ response = client.post(
226
+ "/api/admin/jobs/test-job-123/reset",
227
+ json={"target_state": "failed"},
228
+ )
229
+
230
+ assert response.status_code == 400
231
+
232
+ def test_returns_404_when_job_not_found(self, client):
233
+ """Test 404 when job doesn't exist."""
234
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
235
+ mock_jm = Mock()
236
+ mock_jm.get_job.return_value = None
237
+ mock_jm_class.return_value = mock_jm
238
+
239
+ response = client.post(
240
+ "/api/admin/jobs/nonexistent-job/reset",
241
+ json={"target_state": "pending"},
242
+ )
243
+
244
+ assert response.status_code == 404
245
+ assert "not found" in response.json()["detail"].lower()
246
+
247
+ def test_rejects_missing_target_state(self, client, mock_job):
248
+ """Test that target_state is required."""
249
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
250
+ mock_jm = Mock()
251
+ mock_jm.get_job.return_value = mock_job
252
+ mock_jm_class.return_value = mock_jm
253
+
254
+ response = client.post(
255
+ "/api/admin/jobs/test-job-123/reset",
256
+ json={},
257
+ )
258
+
259
+ assert response.status_code == 422 # Validation error
260
+
261
+
262
+ class TestResetJobLogging:
263
+ """Tests for logging on the reset endpoint."""
264
+
265
+ def test_logs_admin_reset_action(self, client, mock_job):
266
+ """Test that admin reset actions are logged."""
267
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
268
+ patch('backend.api.routes.admin.logger') as mock_logger:
269
+ mock_jm = Mock()
270
+ mock_jm.get_job.return_value = mock_job
271
+ mock_jm.update_job.return_value = True
272
+ mock_jm_class.return_value = mock_jm
273
+
274
+ response = client.post(
275
+ "/api/admin/jobs/test-job-123/reset",
276
+ json={"target_state": "pending"},
277
+ )
278
+
279
+ assert response.status_code == 200
280
+ # Verify logging was called
281
+ mock_logger.info.assert_called()
282
+ log_message = mock_logger.info.call_args[0][0]
283
+ assert "admin" in log_message.lower() or "reset" in log_message.lower()
284
+
285
+
286
+ class TestResetJobTimeline:
287
+ """Tests for timeline event on reset."""
288
+
289
+ def test_adds_timeline_event_on_reset(self, client, mock_job):
290
+ """Test that a timeline event is added when job is reset."""
291
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
292
+ mock_jm = Mock()
293
+ mock_jm.get_job.return_value = mock_job
294
+ mock_jm.update_job.return_value = True
295
+ mock_jm_class.return_value = mock_jm
296
+
297
+ response = client.post(
298
+ "/api/admin/jobs/test-job-123/reset",
299
+ json={"target_state": "awaiting_review"},
300
+ )
301
+
302
+ assert response.status_code == 200
303
+ data = response.json()
304
+ # Timeline event should be included in response
305
+ assert "timeline_event" in data or data.get("message")
306
+
307
+
308
+ class TestResetJobAuthorization:
309
+ """Tests for authorization on the reset endpoint."""
310
+
311
+ def test_requires_admin_access(self, client, mock_job):
312
+ """Test that non-admin users cannot access the endpoint."""
313
+ original_override = app.dependency_overrides.get(require_admin)
314
+
315
+ def get_non_admin():
316
+ from fastapi import HTTPException
317
+ raise HTTPException(status_code=403, detail="Admin access required")
318
+
319
+ app.dependency_overrides[require_admin] = get_non_admin
320
+
321
+ try:
322
+ response = client.post(
323
+ "/api/admin/jobs/test-job-123/reset",
324
+ json={"target_state": "pending"},
325
+ headers={"Authorization": "Bearer user-token"}
326
+ )
327
+ assert response.status_code == 403
328
+ finally:
329
+ if original_override:
330
+ app.dependency_overrides[require_admin] = original_override
331
+ else:
332
+ app.dependency_overrides[require_admin] = get_mock_admin
333
+
334
+
335
+ class TestResetJobClearsStateData:
336
+ """Tests for state_data clearing based on target state."""
337
+
338
+ def test_reset_to_pending_clears_all_processing_data(self, client, mock_job):
339
+ """Test that resetting to PENDING clears audio search results."""
340
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
341
+ mock_jm = Mock()
342
+ mock_jm.get_job.return_value = mock_job
343
+ mock_jm.update_job.return_value = True
344
+ mock_jm_class.return_value = mock_jm
345
+
346
+ response = client.post(
347
+ "/api/admin/jobs/test-job-123/reset",
348
+ json={"target_state": "pending"},
349
+ )
350
+
351
+ assert response.status_code == 200
352
+ # The cleared_data field should list what was cleared
353
+ data = response.json()
354
+ assert "cleared_data" in data or "state_data" in str(data)
355
+
356
+ def test_reset_to_awaiting_review_preserves_audio_data(self, client, mock_job):
357
+ """Test that resetting to AWAITING_REVIEW preserves audio stems."""
358
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
359
+ mock_jm = Mock()
360
+ mock_jm.get_job.return_value = mock_job
361
+ mock_jm.update_job.return_value = True
362
+ mock_jm_class.return_value = mock_jm
363
+
364
+ response = client.post(
365
+ "/api/admin/jobs/test-job-123/reset",
366
+ json={"target_state": "awaiting_review"},
367
+ )
368
+
369
+ assert response.status_code == 200
370
+
371
+ def test_reset_to_instrumental_preserves_review_data(self, client, mock_job):
372
+ """Test that resetting to AWAITING_INSTRUMENTAL_SELECTION preserves review data."""
373
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
374
+ mock_jm = Mock()
375
+ mock_jm.get_job.return_value = mock_job
376
+ mock_jm.update_job.return_value = True
377
+ mock_jm_class.return_value = mock_jm
378
+
379
+ response = client.post(
380
+ "/api/admin/jobs/test-job-123/reset",
381
+ json={"target_state": "awaiting_instrumental_selection"},
382
+ )
383
+
384
+ assert response.status_code == 200