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,396 @@
1
+ """
2
+ Unit tests for RateLimitService.
3
+
4
+ Tests rate limiting logic for jobs, YouTube uploads, and beta enrollments.
5
+ """
6
+
7
+ import pytest
8
+ from unittest.mock import Mock, MagicMock, patch
9
+ from datetime import datetime, timezone
10
+
11
+ # Mock Google Cloud before imports
12
+ import sys
13
+ sys.modules['google.cloud.firestore'] = MagicMock()
14
+ sys.modules['google.cloud.storage'] = MagicMock()
15
+
16
+
17
+ class TestRateLimitService:
18
+ """Test RateLimitService functionality."""
19
+
20
+ @pytest.fixture
21
+ def mock_db(self):
22
+ """Create a mock Firestore client."""
23
+ mock = MagicMock()
24
+ return mock
25
+
26
+ @pytest.fixture
27
+ def mock_settings(self):
28
+ """Create mock settings."""
29
+ settings = Mock()
30
+ settings.enable_rate_limiting = True
31
+ settings.rate_limit_jobs_per_day = 5
32
+ settings.rate_limit_youtube_uploads_per_day = 10
33
+ settings.rate_limit_beta_ip_per_day = 1
34
+ return settings
35
+
36
+ @pytest.fixture
37
+ def rate_limit_service(self, mock_db, mock_settings):
38
+ """Create RateLimitService instance with mocks."""
39
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
40
+ from backend.services.rate_limit_service import RateLimitService
41
+ service = RateLimitService(db=mock_db)
42
+ return service
43
+
44
+ # =========================================================================
45
+ # User Job Rate Limiting Tests
46
+ # =========================================================================
47
+
48
+ def test_check_user_job_limit_under_limit(self, rate_limit_service, mock_db, mock_settings):
49
+ """Test that user under limit is allowed."""
50
+ # Mock: user has 2 jobs today
51
+ mock_doc = Mock()
52
+ mock_doc.exists = True
53
+ mock_doc.to_dict.return_value = {"count": 2}
54
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
55
+
56
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
57
+ allowed, remaining, message = rate_limit_service.check_user_job_limit("user@example.com")
58
+
59
+ assert allowed is True
60
+ assert remaining == 3 # 5 - 2
61
+ assert "3 jobs remaining" in message
62
+
63
+ def test_check_user_job_limit_at_limit(self, rate_limit_service, mock_db, mock_settings):
64
+ """Test that user at limit is blocked."""
65
+ # Mock: user has 5 jobs today (at limit)
66
+ mock_doc = Mock()
67
+ mock_doc.exists = True
68
+ mock_doc.to_dict.return_value = {"count": 5}
69
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
70
+
71
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
72
+ allowed, remaining, message = rate_limit_service.check_user_job_limit("user@example.com")
73
+
74
+ assert allowed is False
75
+ assert remaining == 0
76
+ assert "Daily job limit reached" in message
77
+
78
+ def test_check_user_job_limit_over_limit(self, rate_limit_service, mock_db, mock_settings):
79
+ """Test that user over limit is blocked."""
80
+ # Mock: user somehow has 7 jobs (over limit)
81
+ mock_doc = Mock()
82
+ mock_doc.exists = True
83
+ mock_doc.to_dict.return_value = {"count": 7}
84
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
85
+
86
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
87
+ allowed, remaining, message = rate_limit_service.check_user_job_limit("user@example.com")
88
+
89
+ assert allowed is False
90
+ assert remaining == 0
91
+
92
+ def test_check_user_job_limit_no_jobs_yet(self, rate_limit_service, mock_db, mock_settings):
93
+ """Test user with no jobs today is allowed."""
94
+ # Mock: no document exists
95
+ mock_doc = Mock()
96
+ mock_doc.exists = False
97
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
98
+
99
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
100
+ allowed, remaining, message = rate_limit_service.check_user_job_limit("newuser@example.com")
101
+
102
+ assert allowed is True
103
+ assert remaining == 5
104
+
105
+ def test_check_user_job_limit_with_admin_bypass(self, rate_limit_service, mock_db, mock_settings):
106
+ """Test that admins bypass job limits."""
107
+ # Mock: user at limit
108
+ mock_doc = Mock()
109
+ mock_doc.exists = True
110
+ mock_doc.to_dict.return_value = {"count": 10}
111
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
112
+
113
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
114
+ allowed, remaining, message = rate_limit_service.check_user_job_limit(
115
+ "admin@example.com", is_admin=True
116
+ )
117
+
118
+ assert allowed is True
119
+ assert remaining == -1 # Unlimited for admins
120
+
121
+ def test_check_user_job_limit_with_override_bypass(self, rate_limit_service, mock_db, mock_settings):
122
+ """Test that users with bypass override are allowed."""
123
+ # Mock: user at limit
124
+ mock_job_doc = Mock()
125
+ mock_job_doc.exists = True
126
+ mock_job_doc.to_dict.return_value = {"count": 10}
127
+
128
+ # Mock: user has bypass override
129
+ mock_override_doc = Mock()
130
+ mock_override_doc.exists = True
131
+ mock_override_doc.to_dict.return_value = {"bypass_job_limit": True}
132
+
133
+ def mock_get(*args, **kwargs):
134
+ # Return different docs based on collection
135
+ return mock_override_doc if "overrides" in str(mock_db.collection.call_args) else mock_job_doc
136
+
137
+ mock_db.collection.return_value.document.return_value.get.side_effect = [mock_override_doc, mock_job_doc]
138
+
139
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
140
+ allowed, remaining, message = rate_limit_service.check_user_job_limit("vip@example.com")
141
+
142
+ assert allowed is True
143
+
144
+ def test_check_user_job_limit_with_custom_limit(self, rate_limit_service, mock_db, mock_settings):
145
+ """Test that custom limit from override is respected."""
146
+ # Mock: user has 8 jobs
147
+ mock_job_doc = Mock()
148
+ mock_job_doc.exists = True
149
+ mock_job_doc.to_dict.return_value = {"count": 8}
150
+
151
+ # Mock: user has custom limit of 20
152
+ mock_override_doc = Mock()
153
+ mock_override_doc.exists = True
154
+ mock_override_doc.to_dict.return_value = {"bypass_job_limit": False, "custom_daily_job_limit": 20}
155
+
156
+ mock_db.collection.return_value.document.return_value.get.side_effect = [mock_override_doc, mock_job_doc]
157
+
158
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
159
+ allowed, remaining, message = rate_limit_service.check_user_job_limit("custom@example.com")
160
+
161
+ assert allowed is True
162
+ assert remaining == 12 # 20 - 8
163
+
164
+ def test_check_user_job_limit_rate_limiting_disabled(self, rate_limit_service, mock_settings):
165
+ """Test that rate limiting can be disabled."""
166
+ mock_settings.enable_rate_limiting = False
167
+
168
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
169
+ allowed, remaining, message = rate_limit_service.check_user_job_limit("user@example.com")
170
+
171
+ assert allowed is True
172
+ assert remaining == -1
173
+ assert "Rate limiting disabled" in message
174
+
175
+ # =========================================================================
176
+ # YouTube Upload Rate Limiting Tests
177
+ # =========================================================================
178
+
179
+ def test_check_youtube_limit_under_limit(self, rate_limit_service, mock_db, mock_settings):
180
+ """Test YouTube upload under limit is allowed."""
181
+ mock_doc = Mock()
182
+ mock_doc.exists = True
183
+ mock_doc.to_dict.return_value = {"count": 3}
184
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
185
+
186
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
187
+ allowed, remaining, message = rate_limit_service.check_youtube_upload_limit()
188
+
189
+ assert allowed is True
190
+ assert remaining == 7 # 10 - 3
191
+
192
+ def test_check_youtube_limit_at_limit(self, rate_limit_service, mock_db, mock_settings):
193
+ """Test YouTube upload at limit is blocked."""
194
+ mock_doc = Mock()
195
+ mock_doc.exists = True
196
+ mock_doc.to_dict.return_value = {"count": 10}
197
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
198
+
199
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
200
+ allowed, remaining, message = rate_limit_service.check_youtube_upload_limit()
201
+
202
+ assert allowed is False
203
+ assert remaining == 0
204
+ assert "Daily YouTube upload limit reached" in message
205
+
206
+ def test_check_youtube_limit_no_uploads_yet(self, rate_limit_service, mock_db, mock_settings):
207
+ """Test YouTube upload with no uploads today is allowed."""
208
+ mock_doc = Mock()
209
+ mock_doc.exists = False
210
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
211
+
212
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
213
+ allowed, remaining, message = rate_limit_service.check_youtube_upload_limit()
214
+
215
+ assert allowed is True
216
+ assert remaining == 10
217
+
218
+ def test_check_youtube_limit_zero_configured(self, rate_limit_service, mock_db, mock_settings):
219
+ """Test that zero limit means unlimited YouTube uploads."""
220
+ mock_settings.rate_limit_youtube_uploads_per_day = 0
221
+
222
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
223
+ allowed, remaining, message = rate_limit_service.check_youtube_upload_limit()
224
+
225
+ assert allowed is True
226
+ assert remaining == -1
227
+ assert "No YouTube upload limit" in message
228
+
229
+ # =========================================================================
230
+ # Beta Enrollment IP Rate Limiting Tests
231
+ # =========================================================================
232
+
233
+ def test_check_beta_ip_limit_first_enrollment(self, rate_limit_service, mock_db, mock_settings):
234
+ """Test first enrollment from IP is allowed."""
235
+ mock_doc = Mock()
236
+ mock_doc.exists = False
237
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
238
+
239
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
240
+ allowed, remaining, message = rate_limit_service.check_beta_ip_limit("192.168.1.1")
241
+
242
+ assert allowed is True
243
+ assert remaining == 1
244
+
245
+ def test_check_beta_ip_limit_already_enrolled(self, rate_limit_service, mock_db, mock_settings):
246
+ """Test second enrollment from same IP is blocked."""
247
+ mock_doc = Mock()
248
+ mock_doc.exists = True
249
+ mock_doc.to_dict.return_value = {"count": 1}
250
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
251
+
252
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
253
+ allowed, remaining, message = rate_limit_service.check_beta_ip_limit("192.168.1.1")
254
+
255
+ assert allowed is False
256
+ assert remaining == 0
257
+ assert "Too many beta enrollments" in message
258
+
259
+ # =========================================================================
260
+ # Recording Tests
261
+ # =========================================================================
262
+
263
+ def test_record_job_creation(self, rate_limit_service, mock_db, mock_settings):
264
+ """Test recording a job creation."""
265
+ mock_transaction = Mock()
266
+ mock_db.transaction.return_value = mock_transaction
267
+
268
+ mock_doc = Mock()
269
+ mock_doc.exists = False
270
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
271
+
272
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
273
+ with patch('backend.services.rate_limit_service.firestore') as mock_firestore:
274
+ # Mock the transactional decorator
275
+ mock_firestore.transactional = lambda f: f
276
+ rate_limit_service.record_job_creation("user@example.com", "job123")
277
+
278
+ # Verify transaction was used
279
+ mock_db.transaction.assert_called()
280
+
281
+ def test_record_youtube_upload(self, rate_limit_service, mock_db, mock_settings):
282
+ """Test recording a YouTube upload."""
283
+ mock_transaction = Mock()
284
+ mock_db.transaction.return_value = mock_transaction
285
+
286
+ mock_doc = Mock()
287
+ mock_doc.exists = False
288
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
289
+
290
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
291
+ with patch('backend.services.rate_limit_service.firestore') as mock_firestore:
292
+ mock_firestore.transactional = lambda f: f
293
+ rate_limit_service.record_youtube_upload("job123", "user@example.com")
294
+
295
+ mock_db.transaction.assert_called()
296
+
297
+ def test_record_skipped_when_disabled(self, rate_limit_service, mock_db, mock_settings):
298
+ """Test that recording is skipped when rate limiting is disabled."""
299
+ mock_settings.enable_rate_limiting = False
300
+
301
+ with patch('backend.services.rate_limit_service.settings', mock_settings):
302
+ rate_limit_service.record_job_creation("user@example.com", "job123")
303
+
304
+ # Should not call Firestore
305
+ mock_db.collection.assert_not_called()
306
+
307
+ # =========================================================================
308
+ # Override Management Tests
309
+ # =========================================================================
310
+
311
+ def test_get_user_override_exists(self, rate_limit_service, mock_db):
312
+ """Test getting an existing user override."""
313
+ mock_doc = Mock()
314
+ mock_doc.exists = True
315
+ mock_doc.to_dict.return_value = {
316
+ "email": "vip@example.com",
317
+ "bypass_job_limit": True,
318
+ "reason": "VIP user"
319
+ }
320
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
321
+
322
+ override = rate_limit_service.get_user_override("vip@example.com")
323
+
324
+ assert override is not None
325
+ assert override["bypass_job_limit"] is True
326
+
327
+ def test_get_user_override_not_exists(self, rate_limit_service, mock_db):
328
+ """Test getting a non-existent user override."""
329
+ mock_doc = Mock()
330
+ mock_doc.exists = False
331
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
332
+
333
+ override = rate_limit_service.get_user_override("regular@example.com")
334
+
335
+ assert override is None
336
+
337
+ def test_set_user_override(self, rate_limit_service, mock_db):
338
+ """Test setting a user override."""
339
+ rate_limit_service.set_user_override(
340
+ user_email="vip@example.com",
341
+ bypass_job_limit=True,
342
+ reason="Special access",
343
+ admin_email="admin@example.com"
344
+ )
345
+
346
+ mock_db.collection.return_value.document.return_value.set.assert_called_once()
347
+
348
+ def test_remove_user_override_exists(self, rate_limit_service, mock_db):
349
+ """Test removing an existing user override."""
350
+ mock_doc = Mock()
351
+ mock_doc.exists = True
352
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
353
+
354
+ result = rate_limit_service.remove_user_override("vip@example.com", "admin@example.com")
355
+
356
+ assert result is True
357
+ mock_db.collection.return_value.document.return_value.delete.assert_called_once()
358
+
359
+ def test_remove_user_override_not_exists(self, rate_limit_service, mock_db):
360
+ """Test removing a non-existent user override."""
361
+ mock_doc = Mock()
362
+ mock_doc.exists = False
363
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
364
+
365
+ result = rate_limit_service.remove_user_override("regular@example.com", "admin@example.com")
366
+
367
+ assert result is False
368
+
369
+
370
+ class TestRateLimitExceededError:
371
+ """Test RateLimitExceededError exception."""
372
+
373
+ def test_exception_attributes(self):
374
+ """Test exception has all expected attributes."""
375
+ from backend.exceptions import RateLimitExceededError
376
+
377
+ exc = RateLimitExceededError(
378
+ message="Limit exceeded",
379
+ limit_type="job",
380
+ remaining_seconds=3600,
381
+ current_count=5,
382
+ limit_value=5
383
+ )
384
+
385
+ assert exc.message == "Limit exceeded"
386
+ assert exc.limit_type == "job"
387
+ assert exc.remaining_seconds == 3600
388
+ assert exc.current_count == 5
389
+ assert exc.limit_value == 5
390
+
391
+ def test_exception_str(self):
392
+ """Test exception string representation."""
393
+ from backend.exceptions import RateLimitExceededError
394
+
395
+ exc = RateLimitExceededError(message="Test message")
396
+ assert str(exc) == "Test message"