karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__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 +17 -34
- backend/api/routes/file_upload.py +60 -84
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +11 -3
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +229 -247
- backend/config.py +16 -0
- backend/exceptions.py +66 -0
- backend/main.py +30 -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/email_validation_service.py +646 -0
- backend/services/firestore_service.py +27 -0
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +73 -3
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/conftest.py +7 -1
- 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_audio_search.py +12 -8
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- 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 +2088 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -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
- backend/workers/video_worker_orchestrator.py +26 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
- 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.103.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
backend/tests/test_models.py
CHANGED
|
@@ -913,6 +913,145 @@ class TestJobWithFullTwoPhaseConfig:
|
|
|
913
913
|
assert "title" in job.file_urls["screens"]
|
|
914
914
|
|
|
915
915
|
|
|
916
|
+
class TestMadeForYouFields:
|
|
917
|
+
"""Tests for made-for-you order tracking fields."""
|
|
918
|
+
|
|
919
|
+
def test_job_has_made_for_you_field(self):
|
|
920
|
+
"""Test Job model has made_for_you field with default False."""
|
|
921
|
+
job = Job(
|
|
922
|
+
job_id="test123",
|
|
923
|
+
status=JobStatus.PENDING,
|
|
924
|
+
created_at=datetime.now(UTC),
|
|
925
|
+
updated_at=datetime.now(UTC),
|
|
926
|
+
)
|
|
927
|
+
assert hasattr(job, 'made_for_you')
|
|
928
|
+
assert job.made_for_you is False
|
|
929
|
+
|
|
930
|
+
def test_job_made_for_you_true(self):
|
|
931
|
+
"""Test Job model can set made_for_you to True."""
|
|
932
|
+
job = Job(
|
|
933
|
+
job_id="test123",
|
|
934
|
+
status=JobStatus.PENDING,
|
|
935
|
+
created_at=datetime.now(UTC),
|
|
936
|
+
updated_at=datetime.now(UTC),
|
|
937
|
+
made_for_you=True,
|
|
938
|
+
)
|
|
939
|
+
assert job.made_for_you is True
|
|
940
|
+
|
|
941
|
+
def test_job_has_customer_email_field(self):
|
|
942
|
+
"""Test Job model has customer_email field with default None."""
|
|
943
|
+
job = Job(
|
|
944
|
+
job_id="test123",
|
|
945
|
+
status=JobStatus.PENDING,
|
|
946
|
+
created_at=datetime.now(UTC),
|
|
947
|
+
updated_at=datetime.now(UTC),
|
|
948
|
+
)
|
|
949
|
+
assert hasattr(job, 'customer_email')
|
|
950
|
+
assert job.customer_email is None
|
|
951
|
+
|
|
952
|
+
def test_job_customer_email_set(self):
|
|
953
|
+
"""Test Job model can set customer_email."""
|
|
954
|
+
job = Job(
|
|
955
|
+
job_id="test123",
|
|
956
|
+
status=JobStatus.PENDING,
|
|
957
|
+
created_at=datetime.now(UTC),
|
|
958
|
+
updated_at=datetime.now(UTC),
|
|
959
|
+
customer_email="customer@example.com",
|
|
960
|
+
)
|
|
961
|
+
assert job.customer_email == "customer@example.com"
|
|
962
|
+
|
|
963
|
+
def test_job_has_customer_notes_field(self):
|
|
964
|
+
"""Test Job model has customer_notes field with default None."""
|
|
965
|
+
job = Job(
|
|
966
|
+
job_id="test123",
|
|
967
|
+
status=JobStatus.PENDING,
|
|
968
|
+
created_at=datetime.now(UTC),
|
|
969
|
+
updated_at=datetime.now(UTC),
|
|
970
|
+
)
|
|
971
|
+
assert hasattr(job, 'customer_notes')
|
|
972
|
+
assert job.customer_notes is None
|
|
973
|
+
|
|
974
|
+
def test_job_customer_notes_set(self):
|
|
975
|
+
"""Test Job model can set customer_notes."""
|
|
976
|
+
job = Job(
|
|
977
|
+
job_id="test123",
|
|
978
|
+
status=JobStatus.PENDING,
|
|
979
|
+
created_at=datetime.now(UTC),
|
|
980
|
+
updated_at=datetime.now(UTC),
|
|
981
|
+
customer_notes="Please make it extra special!",
|
|
982
|
+
)
|
|
983
|
+
assert job.customer_notes == "Please make it extra special!"
|
|
984
|
+
|
|
985
|
+
def test_job_create_has_made_for_you_fields(self):
|
|
986
|
+
"""Test JobCreate model has all made-for-you fields."""
|
|
987
|
+
job_create = JobCreate(
|
|
988
|
+
artist="Test Artist",
|
|
989
|
+
title="Test Song",
|
|
990
|
+
)
|
|
991
|
+
assert hasattr(job_create, 'made_for_you')
|
|
992
|
+
assert hasattr(job_create, 'customer_email')
|
|
993
|
+
assert hasattr(job_create, 'customer_notes')
|
|
994
|
+
assert job_create.made_for_you is False
|
|
995
|
+
assert job_create.customer_email is None
|
|
996
|
+
assert job_create.customer_notes is None
|
|
997
|
+
|
|
998
|
+
def test_job_create_with_made_for_you_config(self):
|
|
999
|
+
"""Test JobCreate with full made-for-you configuration."""
|
|
1000
|
+
job_create = JobCreate(
|
|
1001
|
+
artist="Seether",
|
|
1002
|
+
title="Tonight",
|
|
1003
|
+
user_email="admin@nomadkaraoke.com",
|
|
1004
|
+
made_for_you=True,
|
|
1005
|
+
customer_email="customer@example.com",
|
|
1006
|
+
customer_notes="Wedding anniversary!",
|
|
1007
|
+
)
|
|
1008
|
+
assert job_create.made_for_you is True
|
|
1009
|
+
assert job_create.customer_email == "customer@example.com"
|
|
1010
|
+
assert job_create.customer_notes == "Wedding anniversary!"
|
|
1011
|
+
assert job_create.user_email == "admin@nomadkaraoke.com"
|
|
1012
|
+
|
|
1013
|
+
def test_made_for_you_serialization_roundtrip(self):
|
|
1014
|
+
"""Test made-for-you fields survive dict serialization."""
|
|
1015
|
+
job = Job(
|
|
1016
|
+
job_id="test123",
|
|
1017
|
+
status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
1018
|
+
created_at=datetime.now(UTC),
|
|
1019
|
+
updated_at=datetime.now(UTC),
|
|
1020
|
+
made_for_you=True,
|
|
1021
|
+
customer_email="customer@example.com",
|
|
1022
|
+
customer_notes="Test notes",
|
|
1023
|
+
user_email="admin@nomadkaraoke.com",
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
job_dict = job.model_dump()
|
|
1027
|
+
|
|
1028
|
+
assert job_dict['made_for_you'] is True
|
|
1029
|
+
assert job_dict['customer_email'] == "customer@example.com"
|
|
1030
|
+
assert job_dict['customer_notes'] == "Test notes"
|
|
1031
|
+
|
|
1032
|
+
def test_made_for_you_job_with_distribution_settings(self):
|
|
1033
|
+
"""Test made-for-you job with distribution settings."""
|
|
1034
|
+
job = Job(
|
|
1035
|
+
job_id="test123",
|
|
1036
|
+
status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
1037
|
+
created_at=datetime.now(UTC),
|
|
1038
|
+
updated_at=datetime.now(UTC),
|
|
1039
|
+
made_for_you=True,
|
|
1040
|
+
customer_email="customer@example.com",
|
|
1041
|
+
user_email="admin@nomadkaraoke.com",
|
|
1042
|
+
enable_youtube_upload=True,
|
|
1043
|
+
dropbox_path="/Production/Ready To Upload",
|
|
1044
|
+
gdrive_folder_id="1ABC123",
|
|
1045
|
+
brand_prefix="NOMAD",
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
assert job.made_for_you is True
|
|
1049
|
+
assert job.enable_youtube_upload is True
|
|
1050
|
+
assert job.dropbox_path == "/Production/Ready To Upload"
|
|
1051
|
+
assert job.gdrive_folder_id == "1ABC123"
|
|
1052
|
+
assert job.brand_prefix == "NOMAD"
|
|
1053
|
+
|
|
1054
|
+
|
|
916
1055
|
if __name__ == "__main__":
|
|
917
1056
|
pytest.main([__file__, "-v"])
|
|
918
1057
|
|
|
@@ -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"
|