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,392 @@
1
+ """
2
+ Unit tests for rate limits admin API endpoints.
3
+
4
+ Tests the rate limit statistics, blocklist management, and user override endpoints.
5
+ """
6
+ import pytest
7
+ from unittest.mock import Mock, patch, MagicMock
8
+ from fastapi.testclient import TestClient
9
+ from fastapi import FastAPI
10
+ from datetime import datetime, timezone
11
+
12
+ from backend.api.routes.rate_limits import router
13
+ from backend.api.dependencies import require_admin
14
+ from backend.services.auth_service import AuthResult, UserType
15
+
16
+
17
+ # Create a test app with the rate_limits router
18
+ app = FastAPI()
19
+ app.include_router(router, prefix="/api")
20
+
21
+
22
+ def get_mock_admin():
23
+ """Override for require_admin dependency."""
24
+ return AuthResult(
25
+ is_valid=True,
26
+ user_type=UserType.ADMIN,
27
+ remaining_uses=-1,
28
+ message="Admin access granted",
29
+ user_email="admin@example.com",
30
+ is_admin=True,
31
+ )
32
+
33
+
34
+ # Override the require_admin dependency
35
+ app.dependency_overrides[require_admin] = get_mock_admin
36
+
37
+
38
+ @pytest.fixture
39
+ def client():
40
+ """Create a test client."""
41
+ return TestClient(app)
42
+
43
+
44
+ @pytest.fixture
45
+ def mock_settings():
46
+ """Create mock settings."""
47
+ settings = Mock()
48
+ settings.enable_rate_limiting = True
49
+ settings.rate_limit_jobs_per_day = 5
50
+ settings.rate_limit_youtube_uploads_per_day = 10
51
+ settings.rate_limit_beta_ip_per_day = 1
52
+ return settings
53
+
54
+
55
+ class TestGetRateLimitStats:
56
+ """Tests for GET /api/admin/rate-limits/stats endpoint."""
57
+
58
+ def test_returns_stats(self, client, mock_settings):
59
+ """Test successful stats retrieval."""
60
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls, \
61
+ patch('backend.api.routes.rate_limits.get_email_validation_service') as mock_get_evs, \
62
+ patch('backend.api.routes.rate_limits.settings', mock_settings):
63
+
64
+ # Setup rate limit service mock
65
+ mock_rls = Mock()
66
+ mock_rls.get_youtube_uploads_today.return_value = 3
67
+ mock_rls.get_all_overrides.return_value = {"user1@example.com": {}}
68
+ mock_get_rls.return_value = mock_rls
69
+
70
+ # Setup email validation service mock
71
+ mock_evs = Mock()
72
+ mock_evs.get_blocklist_stats.return_value = {
73
+ "disposable_domains_count": 100,
74
+ "blocked_emails_count": 5,
75
+ "blocked_ips_count": 2,
76
+ "default_disposable_domains_count": 130,
77
+ }
78
+ mock_get_evs.return_value = mock_evs
79
+
80
+ response = client.get(
81
+ "/api/admin/rate-limits/stats",
82
+ headers={"Authorization": "Bearer admin-token"}
83
+ )
84
+
85
+ assert response.status_code == 200
86
+ data = response.json()
87
+ assert data["jobs_per_day_limit"] == 5
88
+ assert data["youtube_uploads_per_day_limit"] == 10
89
+ assert data["youtube_uploads_today"] == 3
90
+ assert data["youtube_uploads_remaining"] == 7
91
+ assert data["disposable_domains_count"] == 100
92
+ assert data["total_overrides"] == 1
93
+
94
+
95
+ class TestGetUserRateLimitStatus:
96
+ """Tests for GET /api/admin/rate-limits/users/{email} endpoint."""
97
+
98
+ def test_returns_user_status(self, client, mock_settings):
99
+ """Test successful user status retrieval."""
100
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls, \
101
+ patch('backend.api.routes.rate_limits.settings', mock_settings):
102
+
103
+ mock_rls = Mock()
104
+ mock_rls.get_user_job_count_today.return_value = 2
105
+ mock_rls.get_user_override.return_value = None
106
+ mock_get_rls.return_value = mock_rls
107
+
108
+ response = client.get(
109
+ "/api/admin/rate-limits/users/user@example.com",
110
+ headers={"Authorization": "Bearer admin-token"}
111
+ )
112
+
113
+ assert response.status_code == 200
114
+ data = response.json()
115
+ assert data["email"] == "user@example.com"
116
+ assert data["jobs_today"] == 2
117
+ assert data["jobs_limit"] == 5
118
+ assert data["jobs_remaining"] == 3
119
+ assert data["has_bypass"] is False
120
+
121
+ def test_returns_user_with_bypass(self, client, mock_settings):
122
+ """Test user with bypass override."""
123
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls, \
124
+ patch('backend.api.routes.rate_limits.settings', mock_settings):
125
+
126
+ mock_rls = Mock()
127
+ mock_rls.get_user_job_count_today.return_value = 10
128
+ mock_rls.get_user_override.return_value = {
129
+ "bypass_job_limit": True,
130
+ "reason": "VIP user"
131
+ }
132
+ mock_get_rls.return_value = mock_rls
133
+
134
+ response = client.get(
135
+ "/api/admin/rate-limits/users/vip@example.com",
136
+ headers={"Authorization": "Bearer admin-token"}
137
+ )
138
+
139
+ assert response.status_code == 200
140
+ data = response.json()
141
+ assert data["has_bypass"] is True
142
+ assert data["bypass_reason"] == "VIP user"
143
+ assert data["jobs_remaining"] == -1 # Unlimited
144
+
145
+
146
+ class TestBlocklistEndpoints:
147
+ """Tests for blocklist management endpoints."""
148
+
149
+ def test_get_blocklists(self, client):
150
+ """Test getting all blocklists."""
151
+ with patch('backend.api.routes.rate_limits.get_email_validation_service') as mock_get_evs, \
152
+ patch('backend.services.firestore_service.get_firestore_client') as mock_get_db:
153
+
154
+ mock_evs = Mock()
155
+ mock_evs.get_blocklist_config.return_value = {
156
+ "disposable_domains": {"tempmail.com", "mailinator.com"},
157
+ "blocked_emails": {"spammer@example.com"},
158
+ "blocked_ips": {"192.168.1.100"},
159
+ }
160
+ mock_get_evs.return_value = mock_evs
161
+
162
+ # Mock Firestore for metadata
163
+ mock_db = Mock()
164
+ mock_doc = Mock()
165
+ mock_doc.exists = True
166
+ mock_doc.to_dict.return_value = {
167
+ "updated_at": datetime.now(timezone.utc),
168
+ "updated_by": "admin@example.com"
169
+ }
170
+ mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
171
+ mock_get_db.return_value = mock_db
172
+
173
+ response = client.get(
174
+ "/api/admin/rate-limits/blocklists",
175
+ headers={"Authorization": "Bearer admin-token"}
176
+ )
177
+
178
+ assert response.status_code == 200
179
+ data = response.json()
180
+ assert "tempmail.com" in data["disposable_domains"]
181
+ assert "spammer@example.com" in data["blocked_emails"]
182
+
183
+ def test_add_disposable_domain(self, client):
184
+ """Test adding a disposable domain."""
185
+ with patch('backend.api.routes.rate_limits.get_email_validation_service') as mock_get_evs:
186
+
187
+ mock_evs = Mock()
188
+ mock_evs.add_disposable_domain.return_value = True
189
+ mock_get_evs.return_value = mock_evs
190
+
191
+ response = client.post(
192
+ "/api/admin/rate-limits/blocklists/disposable-domains",
193
+ json={"domain": "newtemp.com"},
194
+ headers={"Authorization": "Bearer admin-token"}
195
+ )
196
+
197
+ assert response.status_code == 200
198
+ data = response.json()
199
+ assert data["success"] is True
200
+ mock_evs.add_disposable_domain.assert_called_once_with("newtemp.com", "admin@example.com")
201
+
202
+ def test_add_disposable_domain_invalid(self, client):
203
+ """Test adding invalid domain."""
204
+ response = client.post(
205
+ "/api/admin/rate-limits/blocklists/disposable-domains",
206
+ json={"domain": "invalid"}, # No dot
207
+ headers={"Authorization": "Bearer admin-token"}
208
+ )
209
+
210
+ assert response.status_code == 400
211
+
212
+ def test_remove_disposable_domain(self, client):
213
+ """Test removing a disposable domain."""
214
+ with patch('backend.api.routes.rate_limits.get_email_validation_service') as mock_get_evs:
215
+
216
+ mock_evs = Mock()
217
+ mock_evs.remove_disposable_domain.return_value = True
218
+ mock_get_evs.return_value = mock_evs
219
+
220
+ response = client.delete(
221
+ "/api/admin/rate-limits/blocklists/disposable-domains/tempmail.com",
222
+ headers={"Authorization": "Bearer admin-token"}
223
+ )
224
+
225
+ assert response.status_code == 200
226
+ mock_evs.remove_disposable_domain.assert_called_once()
227
+
228
+ def test_remove_disposable_domain_not_found(self, client):
229
+ """Test removing non-existent domain."""
230
+ with patch('backend.api.routes.rate_limits.get_email_validation_service') as mock_get_evs:
231
+
232
+ mock_evs = Mock()
233
+ mock_evs.remove_disposable_domain.return_value = False
234
+ mock_get_evs.return_value = mock_evs
235
+
236
+ response = client.delete(
237
+ "/api/admin/rate-limits/blocklists/disposable-domains/notfound.com",
238
+ headers={"Authorization": "Bearer admin-token"}
239
+ )
240
+
241
+ assert response.status_code == 404
242
+
243
+ def test_add_blocked_email(self, client):
244
+ """Test adding a blocked email."""
245
+ with patch('backend.api.routes.rate_limits.get_email_validation_service') as mock_get_evs:
246
+
247
+ mock_evs = Mock()
248
+ mock_evs.add_blocked_email.return_value = True
249
+ mock_get_evs.return_value = mock_evs
250
+
251
+ response = client.post(
252
+ "/api/admin/rate-limits/blocklists/blocked-emails",
253
+ json={"email": "spammer@example.com"},
254
+ headers={"Authorization": "Bearer admin-token"}
255
+ )
256
+
257
+ assert response.status_code == 200
258
+
259
+ def test_add_blocked_ip(self, client):
260
+ """Test adding a blocked IP."""
261
+ with patch('backend.api.routes.rate_limits.get_email_validation_service') as mock_get_evs:
262
+
263
+ mock_evs = Mock()
264
+ mock_evs.add_blocked_ip.return_value = True
265
+ mock_get_evs.return_value = mock_evs
266
+
267
+ response = client.post(
268
+ "/api/admin/rate-limits/blocklists/blocked-ips",
269
+ json={"ip_address": "192.168.1.100"},
270
+ headers={"Authorization": "Bearer admin-token"}
271
+ )
272
+
273
+ assert response.status_code == 200
274
+
275
+
276
+ class TestUserOverrideEndpoints:
277
+ """Tests for user override management endpoints."""
278
+
279
+ def test_get_all_overrides(self, client):
280
+ """Test getting all user overrides."""
281
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls:
282
+
283
+ mock_rls = Mock()
284
+ mock_rls.get_all_overrides.return_value = {
285
+ "vip@example.com": {
286
+ "bypass_job_limit": True,
287
+ "custom_daily_job_limit": None,
288
+ "reason": "VIP user",
289
+ "created_by": "admin@example.com",
290
+ "created_at": datetime.now(timezone.utc),
291
+ }
292
+ }
293
+ mock_get_rls.return_value = mock_rls
294
+
295
+ response = client.get(
296
+ "/api/admin/rate-limits/overrides",
297
+ headers={"Authorization": "Bearer admin-token"}
298
+ )
299
+
300
+ assert response.status_code == 200
301
+ data = response.json()
302
+ assert data["total"] == 1
303
+ assert len(data["overrides"]) == 1
304
+ assert data["overrides"][0]["email"] == "vip@example.com"
305
+
306
+ def test_set_user_override(self, client):
307
+ """Test setting a user override."""
308
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls:
309
+
310
+ mock_rls = Mock()
311
+ mock_get_rls.return_value = mock_rls
312
+
313
+ response = client.put(
314
+ "/api/admin/rate-limits/overrides/user@example.com",
315
+ json={
316
+ "bypass_job_limit": True,
317
+ "reason": "Special access granted"
318
+ },
319
+ headers={"Authorization": "Bearer admin-token"}
320
+ )
321
+
322
+ assert response.status_code == 200
323
+ mock_rls.set_user_override.assert_called_once_with(
324
+ user_email="user@example.com",
325
+ bypass_job_limit=True,
326
+ custom_daily_job_limit=None,
327
+ reason="Special access granted",
328
+ admin_email="admin@example.com",
329
+ )
330
+
331
+ def test_set_user_override_with_custom_limit(self, client):
332
+ """Test setting a user override with custom limit."""
333
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls:
334
+
335
+ mock_rls = Mock()
336
+ mock_get_rls.return_value = mock_rls
337
+
338
+ response = client.put(
339
+ "/api/admin/rate-limits/overrides/user@example.com",
340
+ json={
341
+ "bypass_job_limit": False,
342
+ "custom_daily_job_limit": 20,
343
+ "reason": "High volume user"
344
+ },
345
+ headers={"Authorization": "Bearer admin-token"}
346
+ )
347
+
348
+ assert response.status_code == 200
349
+ mock_rls.set_user_override.assert_called_once()
350
+
351
+ def test_set_user_override_missing_reason(self, client):
352
+ """Test setting override without reason fails."""
353
+ response = client.put(
354
+ "/api/admin/rate-limits/overrides/user@example.com",
355
+ json={
356
+ "bypass_job_limit": True,
357
+ "reason": "ab" # Too short
358
+ },
359
+ headers={"Authorization": "Bearer admin-token"}
360
+ )
361
+
362
+ assert response.status_code == 400
363
+
364
+ def test_remove_user_override(self, client):
365
+ """Test removing a user override."""
366
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls:
367
+
368
+ mock_rls = Mock()
369
+ mock_rls.remove_user_override.return_value = True
370
+ mock_get_rls.return_value = mock_rls
371
+
372
+ response = client.delete(
373
+ "/api/admin/rate-limits/overrides/user@example.com",
374
+ headers={"Authorization": "Bearer admin-token"}
375
+ )
376
+
377
+ assert response.status_code == 200
378
+
379
+ def test_remove_user_override_not_found(self, client):
380
+ """Test removing non-existent override."""
381
+ with patch('backend.api.routes.rate_limits.get_rate_limit_service') as mock_get_rls:
382
+
383
+ mock_rls = Mock()
384
+ mock_rls.remove_user_override.return_value = False
385
+ mock_get_rls.return_value = mock_rls
386
+
387
+ response = client.delete(
388
+ "/api/admin/rate-limits/overrides/notfound@example.com",
389
+ headers={"Authorization": "Bearer admin-token"}
390
+ )
391
+
392
+ assert response.status_code == 404
@@ -0,0 +1,205 @@
1
+ """
2
+ Tests for StripeService.
3
+
4
+ Tests cover:
5
+ - Made-for-you checkout session URL generation
6
+ - Credit purchase checkout session URL generation
7
+ - Package validation
8
+ """
9
+
10
+ import pytest
11
+ from unittest.mock import Mock, patch, MagicMock
12
+ import os
13
+
14
+
15
+ class TestStripeServiceUrls:
16
+ """Tests for checkout session URL generation."""
17
+
18
+ @pytest.fixture
19
+ def stripe_service(self):
20
+ """Create a StripeService instance with mocked Stripe."""
21
+ # Set required env vars
22
+ with patch.dict(os.environ, {
23
+ 'STRIPE_SECRET_KEY': 'sk_test_fake',
24
+ 'FRONTEND_URL': 'https://gen.nomadkaraoke.com',
25
+ }):
26
+ # Import here to pick up env vars
27
+ from backend.services.stripe_service import StripeService
28
+ service = StripeService()
29
+ return service
30
+
31
+ def test_made_for_you_success_url_uses_frontend_url(self, stripe_service):
32
+ """Test that made-for-you checkout uses frontend_url, not hardcoded domain."""
33
+ # Mock stripe.checkout.Session.create to capture the params
34
+ with patch('stripe.checkout.Session.create') as mock_create:
35
+ mock_session = MagicMock()
36
+ mock_session.id = 'cs_test_123'
37
+ mock_session.url = 'https://checkout.stripe.com/test'
38
+ mock_create.return_value = mock_session
39
+
40
+ success, url, message = stripe_service.create_made_for_you_checkout_session(
41
+ customer_email='customer@example.com',
42
+ artist='Test Artist',
43
+ title='Test Song',
44
+ )
45
+
46
+ # Verify the call was made
47
+ assert mock_create.called
48
+ call_kwargs = mock_create.call_args[1]
49
+
50
+ # Verify success_url uses frontend_url and correct path
51
+ success_url = call_kwargs['success_url']
52
+ assert success_url.startswith('https://gen.nomadkaraoke.com')
53
+ assert '/order/success' in success_url
54
+ assert 'session_id=' in success_url
55
+
56
+ def test_made_for_you_success_url_not_hardcoded_to_marketing_site(self, stripe_service):
57
+ """Test that success_url is NOT the old hardcoded marketing site URL."""
58
+ with patch('stripe.checkout.Session.create') as mock_create:
59
+ mock_session = MagicMock()
60
+ mock_session.id = 'cs_test_123'
61
+ mock_session.url = 'https://checkout.stripe.com/test'
62
+ mock_create.return_value = mock_session
63
+
64
+ stripe_service.create_made_for_you_checkout_session(
65
+ customer_email='customer@example.com',
66
+ artist='Test Artist',
67
+ title='Test Song',
68
+ )
69
+
70
+ call_kwargs = mock_create.call_args[1]
71
+ success_url = call_kwargs['success_url']
72
+
73
+ # The bug was: success_url = "https://nomadkaraoke.com/order/success/"
74
+ # (note: nomadkaraoke.com WITHOUT the 'gen.' prefix - that's the marketing site)
75
+ # This should NOT be the case anymore - should use gen.nomadkaraoke.com
76
+ assert not success_url.startswith('https://nomadkaraoke.com/')
77
+ # Should use the frontend URL (gen.nomadkaraoke.com)
78
+ assert success_url.startswith('https://gen.nomadkaraoke.com')
79
+
80
+ def test_made_for_you_respects_custom_frontend_url(self):
81
+ """Test that made-for-you checkout uses custom FRONTEND_URL if set."""
82
+ with patch.dict(os.environ, {
83
+ 'STRIPE_SECRET_KEY': 'sk_test_fake',
84
+ 'FRONTEND_URL': 'https://custom.example.com',
85
+ }):
86
+ from backend.services.stripe_service import StripeService
87
+ service = StripeService()
88
+
89
+ with patch('stripe.checkout.Session.create') as mock_create:
90
+ mock_session = MagicMock()
91
+ mock_session.id = 'cs_test_123'
92
+ mock_session.url = 'https://checkout.stripe.com/test'
93
+ mock_create.return_value = mock_session
94
+
95
+ service.create_made_for_you_checkout_session(
96
+ customer_email='customer@example.com',
97
+ artist='Test Artist',
98
+ title='Test Song',
99
+ )
100
+
101
+ call_kwargs = mock_create.call_args[1]
102
+ success_url = call_kwargs['success_url']
103
+ assert success_url.startswith('https://custom.example.com')
104
+
105
+ def test_credit_purchase_success_url_uses_payment_success(self, stripe_service):
106
+ """Test that credit purchase checkout uses /payment/success path."""
107
+ with patch('stripe.checkout.Session.create') as mock_create:
108
+ mock_session = MagicMock()
109
+ mock_session.id = 'cs_test_123'
110
+ mock_session.url = 'https://checkout.stripe.com/test'
111
+ mock_create.return_value = mock_session
112
+
113
+ success, url, message = stripe_service.create_checkout_session(
114
+ package_id='1_credit',
115
+ user_email='user@example.com',
116
+ )
117
+
118
+ call_kwargs = mock_create.call_args[1]
119
+ success_url = call_kwargs['success_url']
120
+
121
+ # Credit purchases go to /payment/success
122
+ assert '/payment/success' in success_url
123
+ assert success_url.startswith('https://gen.nomadkaraoke.com')
124
+
125
+ def test_made_for_you_uses_order_success_path(self, stripe_service):
126
+ """Test that made-for-you uses /order/success path (not /payment/success)."""
127
+ with patch('stripe.checkout.Session.create') as mock_create:
128
+ mock_session = MagicMock()
129
+ mock_session.id = 'cs_test_123'
130
+ mock_session.url = 'https://checkout.stripe.com/test'
131
+ mock_create.return_value = mock_session
132
+
133
+ stripe_service.create_made_for_you_checkout_session(
134
+ customer_email='customer@example.com',
135
+ artist='Test Artist',
136
+ title='Test Song',
137
+ )
138
+
139
+ call_kwargs = mock_create.call_args[1]
140
+ success_url = call_kwargs['success_url']
141
+
142
+ # Made-for-you orders go to /order/success (different messaging)
143
+ assert '/order/success' in success_url
144
+ # Should NOT go to /payment/success
145
+ assert '/payment/success' not in success_url
146
+
147
+
148
+ class TestStripeServiceMetadata:
149
+ """Tests for checkout session metadata."""
150
+
151
+ @pytest.fixture
152
+ def stripe_service(self):
153
+ """Create a StripeService instance with mocked Stripe."""
154
+ with patch.dict(os.environ, {
155
+ 'STRIPE_SECRET_KEY': 'sk_test_fake',
156
+ 'FRONTEND_URL': 'https://gen.nomadkaraoke.com',
157
+ }):
158
+ from backend.services.stripe_service import StripeService
159
+ return StripeService()
160
+
161
+ def test_made_for_you_includes_order_type_metadata(self, stripe_service):
162
+ """Test that made-for-you checkout includes order_type in metadata."""
163
+ with patch('stripe.checkout.Session.create') as mock_create:
164
+ mock_session = MagicMock()
165
+ mock_session.id = 'cs_test_123'
166
+ mock_session.url = 'https://checkout.stripe.com/test'
167
+ mock_create.return_value = mock_session
168
+
169
+ stripe_service.create_made_for_you_checkout_session(
170
+ customer_email='customer@example.com',
171
+ artist='Test Artist',
172
+ title='Test Song',
173
+ notes='Special request',
174
+ )
175
+
176
+ call_kwargs = mock_create.call_args[1]
177
+ metadata = call_kwargs['metadata']
178
+
179
+ assert metadata['order_type'] == 'made_for_you'
180
+ assert metadata['customer_email'] == 'customer@example.com'
181
+ assert metadata['artist'] == 'Test Artist'
182
+ assert metadata['title'] == 'Test Song'
183
+ assert metadata['notes'] == 'Special request'
184
+
185
+ def test_made_for_you_truncates_long_notes(self, stripe_service):
186
+ """Test that notes longer than 500 chars are truncated."""
187
+ with patch('stripe.checkout.Session.create') as mock_create:
188
+ mock_session = MagicMock()
189
+ mock_session.id = 'cs_test_123'
190
+ mock_session.url = 'https://checkout.stripe.com/test'
191
+ mock_create.return_value = mock_session
192
+
193
+ long_notes = 'x' * 600
194
+
195
+ stripe_service.create_made_for_you_checkout_session(
196
+ customer_email='customer@example.com',
197
+ artist='Test Artist',
198
+ title='Test Song',
199
+ notes=long_notes,
200
+ )
201
+
202
+ call_kwargs = mock_create.call_args[1]
203
+ metadata = call_kwargs['metadata']
204
+
205
+ assert len(metadata['notes']) == 500
@@ -479,10 +479,37 @@ class VideoWorkerOrchestrator:
479
479
  if self.config.gdrive_folder_id:
480
480
  await self._upload_to_gdrive()
481
481
 
482
+ # Clear outputs_deleted_at if set (job was re-processed after output deletion)
483
+ # Only clear if we actually uploaded something
484
+ uploads_happened = (
485
+ self.result.youtube_url or
486
+ self.result.dropbox_link or
487
+ self.result.gdrive_files
488
+ )
489
+ if uploads_happened and self.job_manager:
490
+ job = self.job_manager.get_job(self.config.job_id)
491
+ if job and job.outputs_deleted_at:
492
+ self.job_manager.update_job(self.config.job_id, {
493
+ "outputs_deleted_at": None,
494
+ "outputs_deleted_by": None,
495
+ })
496
+ self.job_log.info("Cleared outputs_deleted_at flag (job was re-processed)")
497
+
482
498
  async def _upload_to_youtube(self):
483
499
  """Upload video to YouTube."""
484
500
  self.job_log.info("Uploading to YouTube")
485
501
 
502
+ # Check YouTube upload rate limit (system-wide)
503
+ try:
504
+ from backend.services.rate_limit_service import get_rate_limit_service
505
+ rate_limit_service = get_rate_limit_service()
506
+ allowed, remaining, message = rate_limit_service.check_youtube_upload_limit()
507
+ if not allowed:
508
+ self.job_log.warning(f"YouTube upload skipped: {message}")
509
+ return
510
+ except Exception as e:
511
+ self.job_log.warning(f"Rate limit check failed, proceeding with upload: {e}")
512
+
486
513
  # Find the best video file to upload (prefer MKV for FLAC audio, then lossless MP4)
487
514
  video_to_upload = None
488
515
  if self.result.final_video_mkv and os.path.isfile(self.result.final_video_mkv):
@@ -520,6 +547,21 @@ class VideoWorkerOrchestrator:
520
547
  if video_url:
521
548
  self.result.youtube_url = video_url
522
549
  self.job_log.info(f"Uploaded to YouTube: {video_url}")
550
+
551
+ # Record the upload for rate limiting
552
+ try:
553
+ # Get user_email from job if available
554
+ user_email = "unknown"
555
+ if self.job_manager:
556
+ job = self.job_manager.get_job(self.config.job_id)
557
+ if job and job.user_email:
558
+ user_email = job.user_email
559
+ rate_limit_service.record_youtube_upload(
560
+ job_id=self.config.job_id,
561
+ user_email=user_email
562
+ )
563
+ except Exception as e:
564
+ self.job_log.warning(f"Failed to record YouTube upload for rate limiting: {e}")
523
565
  else:
524
566
  self.job_log.warning("YouTube upload did not return a URL")
525
567