karaoke-gen 0.101.0__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.
Files changed (41) hide show
  1. backend/Dockerfile.base +1 -0
  2. backend/api/routes/admin.py +226 -3
  3. backend/api/routes/audio_search.py +4 -32
  4. backend/api/routes/file_upload.py +18 -83
  5. backend/api/routes/jobs.py +2 -2
  6. backend/api/routes/push.py +238 -0
  7. backend/api/routes/rate_limits.py +428 -0
  8. backend/api/routes/users.py +79 -19
  9. backend/config.py +25 -1
  10. backend/exceptions.py +66 -0
  11. backend/main.py +26 -1
  12. backend/models/job.py +4 -0
  13. backend/models/user.py +20 -2
  14. backend/services/email_validation_service.py +646 -0
  15. backend/services/firestore_service.py +21 -0
  16. backend/services/gce_encoding/main.py +22 -8
  17. backend/services/job_defaults_service.py +113 -0
  18. backend/services/job_manager.py +109 -13
  19. backend/services/push_notification_service.py +409 -0
  20. backend/services/rate_limit_service.py +641 -0
  21. backend/services/stripe_service.py +2 -2
  22. backend/tests/conftest.py +8 -1
  23. backend/tests/test_admin_delete_outputs.py +352 -0
  24. backend/tests/test_audio_search.py +12 -8
  25. backend/tests/test_email_validation_service.py +298 -0
  26. backend/tests/test_file_upload.py +8 -6
  27. backend/tests/test_gce_encoding_worker.py +229 -0
  28. backend/tests/test_impersonation.py +18 -3
  29. backend/tests/test_made_for_you.py +6 -4
  30. backend/tests/test_push_notification_service.py +460 -0
  31. backend/tests/test_push_routes.py +357 -0
  32. backend/tests/test_rate_limit_service.py +396 -0
  33. backend/tests/test_rate_limits_api.py +392 -0
  34. backend/tests/test_stripe_service.py +205 -0
  35. backend/workers/video_worker_orchestrator.py +42 -0
  36. karaoke_gen/instrumental_review/static/index.html +35 -9
  37. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
  38. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +41 -26
  39. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
  40. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
  41. {karaoke_gen-0.101.0.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
@@ -845,7 +845,8 @@ class TestAudioSearchThemeSupport:
845
845
  This is the key behavior: selecting a theme should automatically
846
846
  enable CDG and TXT output formats.
847
847
  """
848
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
848
+ from backend.api.routes.audio_search import AudioSearchRequest
849
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
849
850
 
850
851
  # When theme_id is set, enable_cdg/enable_txt should default to True
851
852
  request = AudioSearchRequest(
@@ -855,7 +856,7 @@ class TestAudioSearchThemeSupport:
855
856
  # enable_cdg and enable_txt are None (not specified)
856
857
  )
857
858
 
858
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
859
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
859
860
  request.theme_id, request.enable_cdg, request.enable_txt
860
861
  )
861
862
 
@@ -864,7 +865,8 @@ class TestAudioSearchThemeSupport:
864
865
 
865
866
  def test_audio_search_request_no_theme_no_cdg_txt(self):
866
867
  """Test that without theme_id, CDG/TXT defaults to disabled."""
867
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
868
+ from backend.api.routes.audio_search import AudioSearchRequest
869
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
868
870
 
869
871
  request = AudioSearchRequest(
870
872
  artist="Test Artist",
@@ -872,7 +874,7 @@ class TestAudioSearchThemeSupport:
872
874
  # No theme_id, no enable_cdg, no enable_txt
873
875
  )
874
876
 
875
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
877
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
876
878
  request.theme_id, request.enable_cdg, request.enable_txt
877
879
  )
878
880
 
@@ -881,7 +883,8 @@ class TestAudioSearchThemeSupport:
881
883
 
882
884
  def test_explicit_cdg_txt_overrides_theme_default(self):
883
885
  """Test that explicit enable_cdg/enable_txt values override theme defaults."""
884
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
886
+ from backend.api.routes.audio_search import AudioSearchRequest
887
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
885
888
 
886
889
  # Theme set (would default to True), but explicitly disabled
887
890
  request = AudioSearchRequest(
@@ -892,7 +895,7 @@ class TestAudioSearchThemeSupport:
892
895
  enable_txt=False,
893
896
  )
894
897
 
895
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
898
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
896
899
  request.theme_id, request.enable_cdg, request.enable_txt
897
900
  )
898
901
 
@@ -901,7 +904,8 @@ class TestAudioSearchThemeSupport:
901
904
 
902
905
  def test_explicit_cdg_txt_enables_without_theme(self):
903
906
  """Test that explicit True enables CDG/TXT even without theme."""
904
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
907
+ from backend.api.routes.audio_search import AudioSearchRequest
908
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
905
909
 
906
910
  # No theme (would default to False), but explicitly enabled
907
911
  request = AudioSearchRequest(
@@ -911,7 +915,7 @@ class TestAudioSearchThemeSupport:
911
915
  enable_txt=True,
912
916
  )
913
917
 
914
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
918
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
915
919
  request.theme_id, request.enable_cdg, request.enable_txt
916
920
  )
917
921