karaoke-gen 0.103.1__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.
- backend/Dockerfile.base +1 -0
- backend/api/routes/admin.py +226 -3
- backend/api/routes/push.py +238 -0
- backend/config.py +9 -1
- backend/main.py +2 -1
- backend/models/job.py +4 -0
- backend/models/user.py +20 -2
- backend/services/gce_encoding/main.py +22 -8
- backend/services/job_manager.py +68 -11
- backend/services/push_notification_service.py +409 -0
- backend/services/stripe_service.py +2 -2
- backend/tests/conftest.py +2 -1
- backend/tests/test_admin_delete_outputs.py +352 -0
- backend/tests/test_gce_encoding_worker.py +229 -0
- backend/tests/test_impersonation.py +18 -3
- backend/tests/test_push_notification_service.py +460 -0
- backend/tests/test_push_routes.py +357 -0
- backend/tests/test_stripe_service.py +205 -0
- backend/workers/video_worker_orchestrator.py +16 -0
- karaoke_gen/instrumental_review/static/index.html +35 -9
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +25 -18
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.103.1.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
|