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,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Push Notification API routes.
|
|
3
|
+
|
|
4
|
+
Tests the /api/push/* endpoints for subscription management.
|
|
5
|
+
"""
|
|
6
|
+
import pytest
|
|
7
|
+
from unittest.mock import Mock, patch, AsyncMock
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from backend.main import app
|
|
13
|
+
from backend.api.dependencies import require_auth, require_admin
|
|
14
|
+
from backend.services.auth_service import UserType, AuthResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def client():
|
|
19
|
+
"""Create test client."""
|
|
20
|
+
return TestClient(app)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def mock_auth_result():
|
|
25
|
+
"""Create a mock AuthResult for regular user."""
|
|
26
|
+
return AuthResult(
|
|
27
|
+
is_valid=True,
|
|
28
|
+
user_type=UserType.UNLIMITED,
|
|
29
|
+
remaining_uses=-1,
|
|
30
|
+
message="Valid",
|
|
31
|
+
user_email="test@example.com",
|
|
32
|
+
is_admin=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def mock_admin_auth_result():
|
|
38
|
+
"""Create a mock AuthResult for admin user."""
|
|
39
|
+
return AuthResult(
|
|
40
|
+
is_valid=True,
|
|
41
|
+
user_type=UserType.ADMIN,
|
|
42
|
+
remaining_uses=-1,
|
|
43
|
+
message="Valid",
|
|
44
|
+
user_email="admin@nomadkaraoke.com",
|
|
45
|
+
is_admin=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestGetVapidPublicKey:
|
|
50
|
+
"""Tests for GET /api/push/vapid-public-key."""
|
|
51
|
+
|
|
52
|
+
def test_returns_disabled_when_feature_off(self, client):
|
|
53
|
+
"""Returns enabled=false when push notifications disabled."""
|
|
54
|
+
with patch('backend.api.routes.push.get_settings') as mock_settings:
|
|
55
|
+
mock_settings.return_value.enable_push_notifications = False
|
|
56
|
+
|
|
57
|
+
response = client.get("/api/push/vapid-public-key")
|
|
58
|
+
|
|
59
|
+
assert response.status_code == 200
|
|
60
|
+
data = response.json()
|
|
61
|
+
assert data["enabled"] is False
|
|
62
|
+
assert data["vapid_public_key"] is None
|
|
63
|
+
|
|
64
|
+
def test_returns_key_when_enabled(self, client):
|
|
65
|
+
"""Returns public key when push notifications enabled."""
|
|
66
|
+
with patch('backend.api.routes.push.get_settings') as mock_settings, \
|
|
67
|
+
patch('backend.api.routes.push.get_push_notification_service') as mock_service:
|
|
68
|
+
mock_settings.return_value.enable_push_notifications = True
|
|
69
|
+
mock_service.return_value.get_public_key.return_value = "test-public-key-123"
|
|
70
|
+
|
|
71
|
+
response = client.get("/api/push/vapid-public-key")
|
|
72
|
+
|
|
73
|
+
assert response.status_code == 200
|
|
74
|
+
data = response.json()
|
|
75
|
+
assert data["enabled"] is True
|
|
76
|
+
assert data["vapid_public_key"] == "test-public-key-123"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestSubscribe:
|
|
80
|
+
"""Tests for POST /api/push/subscribe."""
|
|
81
|
+
|
|
82
|
+
def test_requires_authentication(self, client):
|
|
83
|
+
"""Returns 401 when not authenticated."""
|
|
84
|
+
# Clear any auth overrides to test real auth
|
|
85
|
+
app.dependency_overrides.clear()
|
|
86
|
+
|
|
87
|
+
response = client.post(
|
|
88
|
+
"/api/push/subscribe",
|
|
89
|
+
json={
|
|
90
|
+
"endpoint": "https://push.example.com/endpoint",
|
|
91
|
+
"keys": {"p256dh": "key1", "auth": "key2"}
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
assert response.status_code == 401
|
|
96
|
+
|
|
97
|
+
def test_returns_503_when_disabled(self, client, mock_auth_result):
|
|
98
|
+
"""Returns 503 when push notifications disabled."""
|
|
99
|
+
async def override_auth():
|
|
100
|
+
return mock_auth_result
|
|
101
|
+
|
|
102
|
+
app.dependency_overrides[require_auth] = override_auth
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with patch('backend.api.routes.push.get_settings') as mock_settings:
|
|
106
|
+
mock_settings.return_value.enable_push_notifications = False
|
|
107
|
+
|
|
108
|
+
response = client.post(
|
|
109
|
+
"/api/push/subscribe",
|
|
110
|
+
json={
|
|
111
|
+
"endpoint": "https://push.example.com/endpoint",
|
|
112
|
+
"keys": {"p256dh": "key1", "auth": "key2"}
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert response.status_code == 503
|
|
117
|
+
assert "not enabled" in response.json()["detail"].lower()
|
|
118
|
+
finally:
|
|
119
|
+
app.dependency_overrides.clear()
|
|
120
|
+
|
|
121
|
+
def test_validates_required_keys(self, client, mock_auth_result):
|
|
122
|
+
"""Returns 400 when keys missing."""
|
|
123
|
+
async def override_auth():
|
|
124
|
+
return mock_auth_result
|
|
125
|
+
|
|
126
|
+
app.dependency_overrides[require_auth] = override_auth
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
with patch('backend.api.routes.push.get_settings') as mock_settings:
|
|
130
|
+
mock_settings.return_value.enable_push_notifications = True
|
|
131
|
+
|
|
132
|
+
response = client.post(
|
|
133
|
+
"/api/push/subscribe",
|
|
134
|
+
json={
|
|
135
|
+
"endpoint": "https://push.example.com/endpoint",
|
|
136
|
+
"keys": {"p256dh": "key1"} # Missing auth
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
assert response.status_code == 400
|
|
141
|
+
assert "missing" in response.json()["detail"].lower()
|
|
142
|
+
finally:
|
|
143
|
+
app.dependency_overrides.clear()
|
|
144
|
+
|
|
145
|
+
def test_successful_subscription(self, client, mock_auth_result):
|
|
146
|
+
"""Successfully subscribes user to push notifications."""
|
|
147
|
+
async def override_auth():
|
|
148
|
+
return mock_auth_result
|
|
149
|
+
|
|
150
|
+
app.dependency_overrides[require_auth] = override_auth
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
with patch('backend.api.routes.push.get_settings') as mock_settings, \
|
|
154
|
+
patch('backend.api.routes.push.get_push_notification_service') as mock_service:
|
|
155
|
+
mock_settings.return_value.enable_push_notifications = True
|
|
156
|
+
mock_service.return_value.add_subscription = AsyncMock(return_value=True)
|
|
157
|
+
|
|
158
|
+
response = client.post(
|
|
159
|
+
"/api/push/subscribe",
|
|
160
|
+
json={
|
|
161
|
+
"endpoint": "https://push.example.com/endpoint",
|
|
162
|
+
"keys": {"p256dh": "key1", "auth": "key2"},
|
|
163
|
+
"device_name": "Test Device"
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert response.status_code == 200
|
|
168
|
+
data = response.json()
|
|
169
|
+
assert data["status"] == "success"
|
|
170
|
+
mock_service.return_value.add_subscription.assert_called_once_with(
|
|
171
|
+
user_email="test@example.com",
|
|
172
|
+
endpoint="https://push.example.com/endpoint",
|
|
173
|
+
keys={"p256dh": "key1", "auth": "key2"},
|
|
174
|
+
device_name="Test Device"
|
|
175
|
+
)
|
|
176
|
+
finally:
|
|
177
|
+
app.dependency_overrides.clear()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestUnsubscribe:
|
|
181
|
+
"""Tests for POST /api/push/unsubscribe."""
|
|
182
|
+
|
|
183
|
+
def test_requires_authentication(self, client):
|
|
184
|
+
"""Returns 401 when not authenticated."""
|
|
185
|
+
app.dependency_overrides.clear()
|
|
186
|
+
|
|
187
|
+
response = client.post(
|
|
188
|
+
"/api/push/unsubscribe",
|
|
189
|
+
json={"endpoint": "https://push.example.com/endpoint"}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
assert response.status_code == 401
|
|
193
|
+
|
|
194
|
+
def test_successful_unsubscribe(self, client, mock_auth_result):
|
|
195
|
+
"""Successfully unsubscribes from push notifications."""
|
|
196
|
+
async def override_auth():
|
|
197
|
+
return mock_auth_result
|
|
198
|
+
|
|
199
|
+
app.dependency_overrides[require_auth] = override_auth
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
with patch('backend.api.routes.push.get_push_notification_service') as mock_service:
|
|
203
|
+
mock_service.return_value.remove_subscription = AsyncMock(return_value=True)
|
|
204
|
+
|
|
205
|
+
response = client.post(
|
|
206
|
+
"/api/push/unsubscribe",
|
|
207
|
+
json={"endpoint": "https://push.example.com/endpoint"}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
assert response.status_code == 200
|
|
211
|
+
data = response.json()
|
|
212
|
+
assert data["status"] == "success"
|
|
213
|
+
mock_service.return_value.remove_subscription.assert_called_once()
|
|
214
|
+
finally:
|
|
215
|
+
app.dependency_overrides.clear()
|
|
216
|
+
|
|
217
|
+
def test_unsubscribe_not_found_succeeds(self, client, mock_auth_result):
|
|
218
|
+
"""Returns success even when subscription not found."""
|
|
219
|
+
async def override_auth():
|
|
220
|
+
return mock_auth_result
|
|
221
|
+
|
|
222
|
+
app.dependency_overrides[require_auth] = override_auth
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
with patch('backend.api.routes.push.get_push_notification_service') as mock_service:
|
|
226
|
+
mock_service.return_value.remove_subscription = AsyncMock(return_value=False)
|
|
227
|
+
|
|
228
|
+
response = client.post(
|
|
229
|
+
"/api/push/unsubscribe",
|
|
230
|
+
json={"endpoint": "https://push.example.com/unknown"}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
assert response.status_code == 200
|
|
234
|
+
data = response.json()
|
|
235
|
+
assert data["status"] == "success"
|
|
236
|
+
finally:
|
|
237
|
+
app.dependency_overrides.clear()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TestListSubscriptions:
|
|
241
|
+
"""Tests for GET /api/push/subscriptions."""
|
|
242
|
+
|
|
243
|
+
def test_requires_authentication(self, client):
|
|
244
|
+
"""Returns 401 when not authenticated."""
|
|
245
|
+
app.dependency_overrides.clear()
|
|
246
|
+
|
|
247
|
+
response = client.get("/api/push/subscriptions")
|
|
248
|
+
assert response.status_code == 401
|
|
249
|
+
|
|
250
|
+
def test_returns_user_subscriptions(self, client, mock_auth_result):
|
|
251
|
+
"""Returns list of user's subscriptions."""
|
|
252
|
+
async def override_auth():
|
|
253
|
+
return mock_auth_result
|
|
254
|
+
|
|
255
|
+
app.dependency_overrides[require_auth] = override_auth
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
with patch('backend.api.routes.push.get_push_notification_service') as mock_service:
|
|
259
|
+
mock_service.return_value.list_subscriptions = AsyncMock(return_value=[
|
|
260
|
+
{
|
|
261
|
+
"endpoint": "https://push.example.com/endpoint1",
|
|
262
|
+
"device_name": "Device 1",
|
|
263
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
264
|
+
"last_used_at": None
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"endpoint": "https://push.example.com/endpoint2",
|
|
268
|
+
"device_name": "Device 2",
|
|
269
|
+
"created_at": "2024-01-02T00:00:00Z",
|
|
270
|
+
"last_used_at": "2024-01-03T00:00:00Z"
|
|
271
|
+
}
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
response = client.get("/api/push/subscriptions")
|
|
275
|
+
|
|
276
|
+
assert response.status_code == 200
|
|
277
|
+
data = response.json()
|
|
278
|
+
assert data["count"] == 2
|
|
279
|
+
assert len(data["subscriptions"]) == 2
|
|
280
|
+
assert data["subscriptions"][0]["device_name"] == "Device 1"
|
|
281
|
+
finally:
|
|
282
|
+
app.dependency_overrides.clear()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class TestTestNotification:
|
|
286
|
+
"""Tests for POST /api/push/test."""
|
|
287
|
+
|
|
288
|
+
def test_requires_admin(self, client, mock_auth_result):
|
|
289
|
+
"""Returns 403 when not admin."""
|
|
290
|
+
# Use a non-admin auth result
|
|
291
|
+
async def override_admin():
|
|
292
|
+
from fastapi import HTTPException
|
|
293
|
+
raise HTTPException(status_code=403, detail="Admin access required")
|
|
294
|
+
|
|
295
|
+
app.dependency_overrides[require_admin] = override_admin
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
response = client.post(
|
|
299
|
+
"/api/push/test",
|
|
300
|
+
json={"title": "Test", "body": "Test message"}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
assert response.status_code == 403
|
|
304
|
+
finally:
|
|
305
|
+
app.dependency_overrides.clear()
|
|
306
|
+
|
|
307
|
+
def test_returns_503_when_disabled(self, client, mock_admin_auth_result):
|
|
308
|
+
"""Returns 503 when push notifications disabled."""
|
|
309
|
+
async def override_admin():
|
|
310
|
+
return mock_admin_auth_result
|
|
311
|
+
|
|
312
|
+
app.dependency_overrides[require_admin] = override_admin
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
with patch('backend.api.routes.push.get_settings') as mock_settings:
|
|
316
|
+
mock_settings.return_value.enable_push_notifications = False
|
|
317
|
+
|
|
318
|
+
response = client.post(
|
|
319
|
+
"/api/push/test",
|
|
320
|
+
json={"title": "Test", "body": "Test message"}
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
assert response.status_code == 503
|
|
324
|
+
finally:
|
|
325
|
+
app.dependency_overrides.clear()
|
|
326
|
+
|
|
327
|
+
def test_sends_test_notification(self, client, mock_admin_auth_result):
|
|
328
|
+
"""Successfully sends test notification to admin."""
|
|
329
|
+
async def override_admin():
|
|
330
|
+
return mock_admin_auth_result
|
|
331
|
+
|
|
332
|
+
app.dependency_overrides[require_admin] = override_admin
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
with patch('backend.api.routes.push.get_settings') as mock_settings, \
|
|
336
|
+
patch('backend.api.routes.push.get_push_notification_service') as mock_service:
|
|
337
|
+
mock_settings.return_value.enable_push_notifications = True
|
|
338
|
+
mock_service.return_value.send_push = AsyncMock(return_value=2)
|
|
339
|
+
|
|
340
|
+
response = client.post(
|
|
341
|
+
"/api/push/test",
|
|
342
|
+
json={"title": "Test Title", "body": "Test Body"}
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
assert response.status_code == 200
|
|
346
|
+
data = response.json()
|
|
347
|
+
assert data["status"] == "success"
|
|
348
|
+
assert data["sent_count"] == 2
|
|
349
|
+
mock_service.return_value.send_push.assert_called_once_with(
|
|
350
|
+
user_email="admin@nomadkaraoke.com",
|
|
351
|
+
title="Test Title",
|
|
352
|
+
body="Test Body",
|
|
353
|
+
url="/app/",
|
|
354
|
+
tag="test"
|
|
355
|
+
)
|
|
356
|
+
finally:
|
|
357
|
+
app.dependency_overrides.clear()
|
|
@@ -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,6 +479,22 @@ 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")
|
|
@@ -750,18 +750,44 @@
|
|
|
750
750
|
const urlParams = new URLSearchParams(window.location.search);
|
|
751
751
|
const encodedBaseApiUrl = urlParams.get('baseApiUrl');
|
|
752
752
|
const instrumentalToken = urlParams.get('instrumentalToken');
|
|
753
|
-
|
|
753
|
+
|
|
754
|
+
// Check localStorage for full auth token (logged-in users)
|
|
755
|
+
// This takes priority over instrumentalToken which can expire
|
|
756
|
+
const fullAuthToken = localStorage.getItem('karaoke_access_token');
|
|
757
|
+
|
|
754
758
|
// Determine API base URL - cloud mode uses provided URL, local mode uses default
|
|
755
|
-
const API_BASE = encodedBaseApiUrl
|
|
759
|
+
const API_BASE = encodedBaseApiUrl
|
|
756
760
|
? decodeURIComponent(encodedBaseApiUrl)
|
|
757
761
|
: '/api/jobs/local';
|
|
758
|
-
|
|
759
|
-
// Helper to add token to URL
|
|
762
|
+
|
|
763
|
+
// Helper to add auth token to URL
|
|
764
|
+
// Priority: fullAuthToken (doesn't expire) > instrumentalToken (expires after 48h)
|
|
765
|
+
// This is essential for audio elements which can't use Bearer headers
|
|
760
766
|
function addTokenToUrl(url) {
|
|
767
|
+
// Prefer full auth token - it doesn't have the short expiry that magic link tokens have
|
|
768
|
+
if (fullAuthToken) {
|
|
769
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
770
|
+
return `${url}${separator}token=${encodeURIComponent(fullAuthToken)}`;
|
|
771
|
+
}
|
|
772
|
+
// Fall back to instrumental token for non-logged-in users
|
|
761
773
|
if (!instrumentalToken) return url;
|
|
762
774
|
const separator = url.includes('?') ? '&' : '?';
|
|
763
775
|
return `${url}${separator}instrumental_token=${encodeURIComponent(instrumentalToken)}`;
|
|
764
776
|
}
|
|
777
|
+
|
|
778
|
+
// Auth-aware fetch helper
|
|
779
|
+
// Priority: fullAuthToken (Bearer header) > instrumentalToken (query param)
|
|
780
|
+
function authFetch(url, options = {}) {
|
|
781
|
+
if (fullAuthToken) {
|
|
782
|
+
// Use Bearer auth for logged-in users
|
|
783
|
+
const headers = new Headers(options.headers || {});
|
|
784
|
+
headers.set('Authorization', `Bearer ${fullAuthToken}`);
|
|
785
|
+
return fetch(url, { ...options, headers });
|
|
786
|
+
} else {
|
|
787
|
+
// Fall back to instrumental token in URL
|
|
788
|
+
return fetch(addTokenToUrl(url), options);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
765
791
|
|
|
766
792
|
// HTML escape helper to prevent XSS
|
|
767
793
|
function escapeHtml(str) {
|
|
@@ -780,8 +806,8 @@
|
|
|
780
806
|
async function init() {
|
|
781
807
|
try {
|
|
782
808
|
const [analysisRes, waveformRes] = await Promise.all([
|
|
783
|
-
|
|
784
|
-
|
|
809
|
+
authFetch(`${API_BASE}/instrumental-analysis`),
|
|
810
|
+
authFetch(`${API_BASE}/waveform-data?num_points=1000`)
|
|
785
811
|
]);
|
|
786
812
|
|
|
787
813
|
if (!analysisRes.ok) throw new Error('Failed to load analysis');
|
|
@@ -1484,7 +1510,7 @@
|
|
|
1484
1510
|
const formData = new FormData();
|
|
1485
1511
|
formData.append('file', file);
|
|
1486
1512
|
|
|
1487
|
-
const response = await
|
|
1513
|
+
const response = await authFetch(`${API_BASE}/upload-instrumental`, {
|
|
1488
1514
|
method: 'POST',
|
|
1489
1515
|
body: formData
|
|
1490
1516
|
});
|
|
@@ -1543,7 +1569,7 @@
|
|
|
1543
1569
|
}
|
|
1544
1570
|
|
|
1545
1571
|
try {
|
|
1546
|
-
const response = await
|
|
1572
|
+
const response = await authFetch(`${API_BASE}/create-custom-instrumental`, {
|
|
1547
1573
|
method: 'POST',
|
|
1548
1574
|
headers: { 'Content-Type': 'application/json' },
|
|
1549
1575
|
body: JSON.stringify({ mute_regions: muteRegions })
|
|
@@ -1593,7 +1619,7 @@
|
|
|
1593
1619
|
}
|
|
1594
1620
|
|
|
1595
1621
|
try {
|
|
1596
|
-
const response = await
|
|
1622
|
+
const response = await authFetch(`${API_BASE}/select-instrumental`, {
|
|
1597
1623
|
method: 'POST',
|
|
1598
1624
|
headers: { 'Content-Type': 'application/json' },
|
|
1599
1625
|
body: JSON.stringify({ selection: selectedOption })
|