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,460 @@
1
+ """
2
+ Tests for PushNotificationService.
3
+
4
+ Tests the Web Push notification sending and subscription management.
5
+ """
6
+ import pytest
7
+ from unittest.mock import Mock, patch, AsyncMock, MagicMock
8
+ from datetime import datetime, timezone
9
+
10
+ from backend.services.push_notification_service import (
11
+ PushNotificationService,
12
+ get_push_notification_service,
13
+ SubscriptionGoneError,
14
+ )
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_settings():
19
+ """Mock settings with push notifications enabled."""
20
+ settings = Mock()
21
+ settings.enable_push_notifications = True
22
+ settings.max_push_subscriptions_per_user = 5
23
+ settings.vapid_subject = "mailto:test@example.com"
24
+ settings.get_secret = Mock(side_effect=lambda x: {
25
+ "vapid-public-key": "test-public-key",
26
+ "vapid-private-key": "test-private-key"
27
+ }.get(x))
28
+ return settings
29
+
30
+
31
+ @pytest.fixture
32
+ def mock_db():
33
+ """Mock Firestore client."""
34
+ return Mock()
35
+
36
+
37
+ @pytest.fixture
38
+ def push_service(mock_settings, mock_db):
39
+ """Create PushNotificationService with mocked dependencies."""
40
+ with patch('backend.services.push_notification_service.get_settings', return_value=mock_settings):
41
+ service = PushNotificationService(db=mock_db)
42
+ return service
43
+
44
+
45
+ class TestPushNotificationServiceInit:
46
+ """Tests for service initialization and configuration."""
47
+
48
+ def test_is_enabled_when_configured(self, push_service):
49
+ """Service reports enabled when all config present."""
50
+ assert push_service.is_enabled() is True
51
+
52
+ def test_is_disabled_when_feature_flag_off(self, mock_db):
53
+ """Service reports disabled when feature flag off."""
54
+ settings = Mock()
55
+ settings.enable_push_notifications = False
56
+ settings.get_secret = Mock(return_value="key")
57
+
58
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
59
+ service = PushNotificationService(db=mock_db)
60
+ assert service.is_enabled() is False
61
+
62
+ def test_is_disabled_when_vapid_keys_missing(self, mock_db):
63
+ """Service reports disabled when VAPID keys missing."""
64
+ settings = Mock()
65
+ settings.enable_push_notifications = True
66
+ settings.get_secret = Mock(return_value=None)
67
+
68
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
69
+ service = PushNotificationService(db=mock_db)
70
+ assert service.is_enabled() is False
71
+
72
+ def test_get_public_key(self, push_service):
73
+ """Service returns public key when enabled."""
74
+ assert push_service.get_public_key() == "test-public-key"
75
+
76
+ def test_get_public_key_returns_none_when_disabled(self, mock_db):
77
+ """Service returns None for public key when disabled."""
78
+ settings = Mock()
79
+ settings.enable_push_notifications = False
80
+ settings.get_secret = Mock(return_value="key")
81
+
82
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
83
+ service = PushNotificationService(db=mock_db)
84
+ assert service.get_public_key() is None
85
+
86
+
87
+ class TestSendPush:
88
+ """Tests for sending push notifications."""
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_send_push_skips_when_disabled(self, mock_db):
92
+ """send_push returns 0 when push notifications disabled."""
93
+ settings = Mock()
94
+ settings.enable_push_notifications = False
95
+ settings.get_secret = Mock(return_value=None)
96
+
97
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
98
+ service = PushNotificationService(db=mock_db)
99
+ result = await service.send_push("test@example.com", "Title", "Body")
100
+ assert result == 0
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_send_push_no_user(self, push_service):
104
+ """send_push returns 0 when user not found."""
105
+ # Mock user not existing
106
+ push_service.db.collection.return_value.document.return_value.get.return_value.exists = False
107
+
108
+ result = await push_service.send_push("unknown@example.com", "Title", "Body")
109
+ assert result == 0
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_send_push_no_subscriptions(self, push_service):
113
+ """send_push returns 0 when user has no subscriptions."""
114
+ # Mock user with no subscriptions
115
+ mock_doc = Mock()
116
+ mock_doc.exists = True
117
+ mock_doc.to_dict.return_value = {"push_subscriptions": []}
118
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
119
+
120
+ result = await push_service.send_push("test@example.com", "Title", "Body")
121
+ assert result == 0
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_send_push_success(self, push_service):
125
+ """send_push successfully sends to subscription."""
126
+ # Mock user with subscription
127
+ mock_doc = Mock()
128
+ mock_doc.exists = True
129
+ mock_doc.to_dict.return_value = {
130
+ "push_subscriptions": [{
131
+ "endpoint": "https://push.example.com/endpoint",
132
+ "keys": {"p256dh": "key1", "auth": "key2"},
133
+ "device_name": "Test Device"
134
+ }]
135
+ }
136
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
137
+
138
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
139
+ result = await push_service.send_push("test@example.com", "Title", "Body")
140
+
141
+ assert result == 1
142
+ mock_webpush.assert_called_once()
143
+ call_args = mock_webpush.call_args
144
+ assert call_args[1]["subscription_info"]["endpoint"] == "https://push.example.com/endpoint"
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_send_push_removes_gone_subscription(self, push_service):
148
+ """send_push removes subscription when 410 Gone returned."""
149
+ from pywebpush import WebPushException
150
+
151
+ # Mock user with subscription
152
+ mock_doc = Mock()
153
+ mock_doc.exists = True
154
+ mock_doc.to_dict.return_value = {
155
+ "push_subscriptions": [{
156
+ "endpoint": "https://push.example.com/endpoint",
157
+ "keys": {"p256dh": "key1", "auth": "key2"},
158
+ "device_name": "Test Device"
159
+ }]
160
+ }
161
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
162
+
163
+ # Mock webpush to raise 410 error
164
+ mock_response = Mock()
165
+ mock_response.status_code = 410
166
+ error = WebPushException("Gone", response=mock_response)
167
+
168
+ with patch('backend.services.push_notification_service.webpush', side_effect=error):
169
+ result = await push_service.send_push("test@example.com", "Title", "Body")
170
+
171
+ assert result == 0
172
+ # Verify invalid subscription was cleaned up
173
+ push_service.db.collection.return_value.document.return_value.update.assert_called()
174
+
175
+
176
+ class TestSubscriptionManagement:
177
+ """Tests for adding, removing, and listing subscriptions."""
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_add_subscription_new_user(self, push_service):
181
+ """add_subscription returns False for non-existent user."""
182
+ push_service.db.collection.return_value.document.return_value.get.return_value.exists = False
183
+
184
+ result = await push_service.add_subscription(
185
+ "unknown@example.com",
186
+ "https://push.example.com/endpoint",
187
+ {"p256dh": "key1", "auth": "key2"},
188
+ "Test Device"
189
+ )
190
+
191
+ assert result is False
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_add_subscription_success(self, push_service):
195
+ """add_subscription adds new subscription."""
196
+ mock_doc = Mock()
197
+ mock_doc.exists = True
198
+ mock_doc.to_dict.return_value = {"push_subscriptions": []}
199
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
200
+
201
+ result = await push_service.add_subscription(
202
+ "test@example.com",
203
+ "https://push.example.com/endpoint",
204
+ {"p256dh": "key1", "auth": "key2"},
205
+ "Test Device"
206
+ )
207
+
208
+ assert result is True
209
+ push_service.db.collection.return_value.document.return_value.update.assert_called_once()
210
+
211
+ @pytest.mark.asyncio
212
+ async def test_add_subscription_updates_existing(self, push_service):
213
+ """add_subscription updates existing subscription with same endpoint."""
214
+ mock_doc = Mock()
215
+ mock_doc.exists = True
216
+ mock_doc.to_dict.return_value = {
217
+ "push_subscriptions": [{
218
+ "endpoint": "https://push.example.com/endpoint",
219
+ "keys": {"p256dh": "old-key", "auth": "old-auth"},
220
+ "device_name": "Old Device"
221
+ }]
222
+ }
223
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
224
+
225
+ result = await push_service.add_subscription(
226
+ "test@example.com",
227
+ "https://push.example.com/endpoint",
228
+ {"p256dh": "new-key", "auth": "new-auth"},
229
+ "New Device"
230
+ )
231
+
232
+ assert result is True
233
+ # Verify update was called (subscription replaced, not added)
234
+ update_call = push_service.db.collection.return_value.document.return_value.update.call_args
235
+ subs = update_call[0][0]["push_subscriptions"]
236
+ assert len(subs) == 1
237
+ assert subs[0]["device_name"] == "New Device"
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_add_subscription_enforces_max_limit(self, push_service):
241
+ """add_subscription removes oldest when max exceeded."""
242
+ # Create 5 existing subscriptions
243
+ existing_subs = [
244
+ {
245
+ "endpoint": f"https://push.example.com/endpoint{i}",
246
+ "keys": {"p256dh": "key", "auth": "auth"},
247
+ "device_name": f"Device {i}",
248
+ "created_at": f"2024-01-0{i+1}T00:00:00Z"
249
+ }
250
+ for i in range(5)
251
+ ]
252
+
253
+ mock_doc = Mock()
254
+ mock_doc.exists = True
255
+ mock_doc.to_dict.return_value = {"push_subscriptions": existing_subs}
256
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
257
+
258
+ result = await push_service.add_subscription(
259
+ "test@example.com",
260
+ "https://push.example.com/new-endpoint",
261
+ {"p256dh": "new-key", "auth": "new-auth"},
262
+ "New Device"
263
+ )
264
+
265
+ assert result is True
266
+ # Verify oldest was removed (max 5 subscriptions)
267
+ update_call = push_service.db.collection.return_value.document.return_value.update.call_args
268
+ subs = update_call[0][0]["push_subscriptions"]
269
+ assert len(subs) == 5
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_remove_subscription_success(self, push_service):
273
+ """remove_subscription removes existing subscription."""
274
+ mock_doc = Mock()
275
+ mock_doc.exists = True
276
+ mock_doc.to_dict.return_value = {
277
+ "push_subscriptions": [{
278
+ "endpoint": "https://push.example.com/endpoint",
279
+ "keys": {"p256dh": "key", "auth": "auth"},
280
+ "device_name": "Test Device"
281
+ }]
282
+ }
283
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
284
+
285
+ result = await push_service.remove_subscription(
286
+ "test@example.com",
287
+ "https://push.example.com/endpoint"
288
+ )
289
+
290
+ assert result is True
291
+ update_call = push_service.db.collection.return_value.document.return_value.update.call_args
292
+ subs = update_call[0][0]["push_subscriptions"]
293
+ assert len(subs) == 0
294
+
295
+ @pytest.mark.asyncio
296
+ async def test_remove_subscription_not_found(self, push_service):
297
+ """remove_subscription returns False when subscription not found."""
298
+ mock_doc = Mock()
299
+ mock_doc.exists = True
300
+ mock_doc.to_dict.return_value = {"push_subscriptions": []}
301
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
302
+
303
+ result = await push_service.remove_subscription(
304
+ "test@example.com",
305
+ "https://push.example.com/unknown-endpoint"
306
+ )
307
+
308
+ assert result is False
309
+
310
+ @pytest.mark.asyncio
311
+ async def test_list_subscriptions_success(self, push_service):
312
+ """list_subscriptions returns user's subscriptions."""
313
+ mock_doc = Mock()
314
+ mock_doc.exists = True
315
+ mock_doc.to_dict.return_value = {
316
+ "push_subscriptions": [
317
+ {
318
+ "endpoint": "https://push.example.com/endpoint1",
319
+ "keys": {"p256dh": "key", "auth": "auth"},
320
+ "device_name": "Device 1",
321
+ "created_at": "2024-01-01T00:00:00Z",
322
+ "last_used_at": None
323
+ },
324
+ {
325
+ "endpoint": "https://push.example.com/endpoint2",
326
+ "keys": {"p256dh": "key", "auth": "auth"},
327
+ "device_name": "Device 2",
328
+ "created_at": "2024-01-02T00:00:00Z",
329
+ "last_used_at": "2024-01-03T00:00:00Z"
330
+ }
331
+ ]
332
+ }
333
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
334
+
335
+ result = await push_service.list_subscriptions("test@example.com")
336
+
337
+ assert len(result) == 2
338
+ assert result[0]["device_name"] == "Device 1"
339
+ assert result[1]["device_name"] == "Device 2"
340
+ # Verify keys are NOT included in response (security)
341
+ assert "keys" not in result[0]
342
+
343
+
344
+ class TestNotificationFormatting:
345
+ """Tests for blocking and completion notification formatting."""
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_send_blocking_notification_lyrics(self, push_service):
349
+ """send_blocking_notification formats lyrics review notification."""
350
+ mock_doc = Mock()
351
+ mock_doc.exists = True
352
+ mock_doc.to_dict.return_value = {
353
+ "push_subscriptions": [{
354
+ "endpoint": "https://push.example.com/endpoint",
355
+ "keys": {"p256dh": "key", "auth": "auth"}
356
+ }]
357
+ }
358
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
359
+
360
+ job = {
361
+ "job_id": "test-job-123",
362
+ "user_email": "test@example.com",
363
+ "artist": "Test Artist",
364
+ "title": "Test Song"
365
+ }
366
+
367
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
368
+ await push_service.send_blocking_notification(job, "lyrics")
369
+
370
+ call_args = mock_webpush.call_args
371
+ import json
372
+ payload = json.loads(call_args[1]["data"])
373
+ assert payload["title"] == "Review Lyrics"
374
+ assert "Test Song" in payload["body"]
375
+ assert "Test Artist" in payload["body"]
376
+ assert "/review/test-job-123" in payload["url"]
377
+
378
+ @pytest.mark.asyncio
379
+ async def test_send_blocking_notification_instrumental(self, push_service):
380
+ """send_blocking_notification formats instrumental selection notification."""
381
+ mock_doc = Mock()
382
+ mock_doc.exists = True
383
+ mock_doc.to_dict.return_value = {
384
+ "push_subscriptions": [{
385
+ "endpoint": "https://push.example.com/endpoint",
386
+ "keys": {"p256dh": "key", "auth": "auth"}
387
+ }]
388
+ }
389
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
390
+
391
+ job = {
392
+ "job_id": "test-job-123",
393
+ "user_email": "test@example.com",
394
+ "artist": "Test Artist",
395
+ "title": "Test Song"
396
+ }
397
+
398
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
399
+ await push_service.send_blocking_notification(job, "instrumental")
400
+
401
+ call_args = mock_webpush.call_args
402
+ import json
403
+ payload = json.loads(call_args[1]["data"])
404
+ assert payload["title"] == "Select Instrumental"
405
+ assert "/instrumental/test-job-123" in payload["url"]
406
+
407
+ @pytest.mark.asyncio
408
+ async def test_send_completion_notification(self, push_service):
409
+ """send_completion_notification formats completion notification."""
410
+ mock_doc = Mock()
411
+ mock_doc.exists = True
412
+ mock_doc.to_dict.return_value = {
413
+ "push_subscriptions": [{
414
+ "endpoint": "https://push.example.com/endpoint",
415
+ "keys": {"p256dh": "key", "auth": "auth"}
416
+ }]
417
+ }
418
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
419
+
420
+ job = {
421
+ "job_id": "test-job-123",
422
+ "user_email": "test@example.com",
423
+ "artist": "Test Artist",
424
+ "title": "Test Song"
425
+ }
426
+
427
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
428
+ await push_service.send_completion_notification(job)
429
+
430
+ call_args = mock_webpush.call_args
431
+ import json
432
+ payload = json.loads(call_args[1]["data"])
433
+ assert payload["title"] == "Video Ready!"
434
+ assert "Test Song" in payload["body"]
435
+ assert "Test Artist" in payload["body"]
436
+ assert "download" in payload["body"].lower()
437
+
438
+
439
+ class TestSingleton:
440
+ """Tests for singleton pattern."""
441
+
442
+ def test_get_push_notification_service_returns_singleton(self):
443
+ """get_push_notification_service returns same instance."""
444
+ # Reset singleton for test
445
+ import backend.services.push_notification_service as module
446
+ module._push_service = None
447
+
448
+ with patch('backend.services.push_notification_service.get_settings') as mock_get_settings:
449
+ mock_settings = Mock()
450
+ mock_settings.enable_push_notifications = False
451
+ mock_settings.get_secret = Mock(return_value=None)
452
+ mock_get_settings.return_value = mock_settings
453
+
454
+ service1 = get_push_notification_service()
455
+ service2 = get_push_notification_service()
456
+
457
+ assert service1 is service2
458
+
459
+ # Clean up
460
+ module._push_service = None