karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/api/routes/admin.py +696 -92
- backend/api/routes/audio_search.py +29 -8
- backend/api/routes/file_upload.py +99 -22
- backend/api/routes/health.py +65 -0
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +28 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +472 -51
- backend/main.py +31 -2
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/encoding_service.py +128 -31
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +44 -2
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +133 -11
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +171 -9
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,2086 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive tests for Made-For-You order flow.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- New model fields (made_for_you, customer_email, customer_notes)
|
|
6
|
+
- Email service methods (order confirmation, admin notification)
|
|
7
|
+
- Ownership transfer on job completion
|
|
8
|
+
- Email suppression for intermediate reminders
|
|
9
|
+
- Integration scenarios
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from datetime import datetime, UTC
|
|
14
|
+
from unittest.mock import Mock, MagicMock, patch, AsyncMock
|
|
15
|
+
|
|
16
|
+
from backend.models.job import Job, JobCreate, JobStatus
|
|
17
|
+
from backend.services.email_service import EmailService
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# Model Tests - New Fields
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
class TestMadeForYouModelFields:
|
|
25
|
+
"""Tests for made-for-you fields on Job and JobCreate models."""
|
|
26
|
+
|
|
27
|
+
def test_job_has_made_for_you_field(self):
|
|
28
|
+
"""Test Job model has made_for_you field."""
|
|
29
|
+
job = Job(
|
|
30
|
+
job_id="test123",
|
|
31
|
+
status=JobStatus.PENDING,
|
|
32
|
+
created_at=datetime.now(UTC),
|
|
33
|
+
updated_at=datetime.now(UTC),
|
|
34
|
+
)
|
|
35
|
+
assert hasattr(job, 'made_for_you')
|
|
36
|
+
assert job.made_for_you is False # Default value
|
|
37
|
+
|
|
38
|
+
def test_job_made_for_you_can_be_set_true(self):
|
|
39
|
+
"""Test Job model made_for_you can be set to True."""
|
|
40
|
+
job = Job(
|
|
41
|
+
job_id="test123",
|
|
42
|
+
status=JobStatus.PENDING,
|
|
43
|
+
created_at=datetime.now(UTC),
|
|
44
|
+
updated_at=datetime.now(UTC),
|
|
45
|
+
made_for_you=True,
|
|
46
|
+
)
|
|
47
|
+
assert job.made_for_you is True
|
|
48
|
+
|
|
49
|
+
def test_job_has_customer_email_field(self):
|
|
50
|
+
"""Test Job model has customer_email field."""
|
|
51
|
+
job = Job(
|
|
52
|
+
job_id="test123",
|
|
53
|
+
status=JobStatus.PENDING,
|
|
54
|
+
created_at=datetime.now(UTC),
|
|
55
|
+
updated_at=datetime.now(UTC),
|
|
56
|
+
)
|
|
57
|
+
assert hasattr(job, 'customer_email')
|
|
58
|
+
assert job.customer_email is None # Default value
|
|
59
|
+
|
|
60
|
+
def test_job_customer_email_can_be_set(self):
|
|
61
|
+
"""Test Job model customer_email can be set."""
|
|
62
|
+
job = Job(
|
|
63
|
+
job_id="test123",
|
|
64
|
+
status=JobStatus.PENDING,
|
|
65
|
+
created_at=datetime.now(UTC),
|
|
66
|
+
updated_at=datetime.now(UTC),
|
|
67
|
+
customer_email="customer@example.com",
|
|
68
|
+
)
|
|
69
|
+
assert job.customer_email == "customer@example.com"
|
|
70
|
+
|
|
71
|
+
def test_job_has_customer_notes_field(self):
|
|
72
|
+
"""Test Job model has customer_notes field."""
|
|
73
|
+
job = Job(
|
|
74
|
+
job_id="test123",
|
|
75
|
+
status=JobStatus.PENDING,
|
|
76
|
+
created_at=datetime.now(UTC),
|
|
77
|
+
updated_at=datetime.now(UTC),
|
|
78
|
+
)
|
|
79
|
+
assert hasattr(job, 'customer_notes')
|
|
80
|
+
assert job.customer_notes is None # Default value
|
|
81
|
+
|
|
82
|
+
def test_job_customer_notes_can_be_set(self):
|
|
83
|
+
"""Test Job model customer_notes can be set."""
|
|
84
|
+
job = Job(
|
|
85
|
+
job_id="test123",
|
|
86
|
+
status=JobStatus.PENDING,
|
|
87
|
+
created_at=datetime.now(UTC),
|
|
88
|
+
updated_at=datetime.now(UTC),
|
|
89
|
+
customer_notes="Please make this extra special!",
|
|
90
|
+
)
|
|
91
|
+
assert job.customer_notes == "Please make this extra special!"
|
|
92
|
+
|
|
93
|
+
def test_job_create_has_made_for_you_field(self):
|
|
94
|
+
"""Test JobCreate model has made_for_you field."""
|
|
95
|
+
job_create = JobCreate(
|
|
96
|
+
artist="Test Artist",
|
|
97
|
+
title="Test Song",
|
|
98
|
+
)
|
|
99
|
+
assert hasattr(job_create, 'made_for_you')
|
|
100
|
+
assert job_create.made_for_you is False
|
|
101
|
+
|
|
102
|
+
def test_job_create_has_customer_email_field(self):
|
|
103
|
+
"""Test JobCreate model has customer_email field."""
|
|
104
|
+
job_create = JobCreate(
|
|
105
|
+
artist="Test Artist",
|
|
106
|
+
title="Test Song",
|
|
107
|
+
)
|
|
108
|
+
assert hasattr(job_create, 'customer_email')
|
|
109
|
+
assert job_create.customer_email is None
|
|
110
|
+
|
|
111
|
+
def test_job_create_has_customer_notes_field(self):
|
|
112
|
+
"""Test JobCreate model has customer_notes field."""
|
|
113
|
+
job_create = JobCreate(
|
|
114
|
+
artist="Test Artist",
|
|
115
|
+
title="Test Song",
|
|
116
|
+
)
|
|
117
|
+
assert hasattr(job_create, 'customer_notes')
|
|
118
|
+
assert job_create.customer_notes is None
|
|
119
|
+
|
|
120
|
+
def test_job_create_made_for_you_full_config(self):
|
|
121
|
+
"""Test JobCreate with all made-for-you fields configured."""
|
|
122
|
+
job_create = JobCreate(
|
|
123
|
+
artist="Seether",
|
|
124
|
+
title="Tonight",
|
|
125
|
+
user_email="admin@nomadkaraoke.com",
|
|
126
|
+
made_for_you=True,
|
|
127
|
+
customer_email="customer@example.com",
|
|
128
|
+
customer_notes="Wedding anniversary song!",
|
|
129
|
+
)
|
|
130
|
+
assert job_create.made_for_you is True
|
|
131
|
+
assert job_create.customer_email == "customer@example.com"
|
|
132
|
+
assert job_create.customer_notes == "Wedding anniversary song!"
|
|
133
|
+
assert job_create.user_email == "admin@nomadkaraoke.com"
|
|
134
|
+
|
|
135
|
+
def test_job_roundtrip_serialization(self):
|
|
136
|
+
"""Test Job with made-for-you fields survives dict serialization."""
|
|
137
|
+
job = Job(
|
|
138
|
+
job_id="test123",
|
|
139
|
+
status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
140
|
+
created_at=datetime.now(UTC),
|
|
141
|
+
updated_at=datetime.now(UTC),
|
|
142
|
+
made_for_you=True,
|
|
143
|
+
customer_email="customer@example.com",
|
|
144
|
+
customer_notes="Test notes",
|
|
145
|
+
user_email="admin@nomadkaraoke.com",
|
|
146
|
+
artist="Test Artist",
|
|
147
|
+
title="Test Song",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
job_dict = job.dict()
|
|
151
|
+
assert job_dict['made_for_you'] is True
|
|
152
|
+
assert job_dict['customer_email'] == "customer@example.com"
|
|
153
|
+
assert job_dict['customer_notes'] == "Test notes"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# =============================================================================
|
|
157
|
+
# Email Service Tests - Made-For-You Methods
|
|
158
|
+
# =============================================================================
|
|
159
|
+
|
|
160
|
+
class TestMadeForYouOrderConfirmationEmail:
|
|
161
|
+
"""Tests for send_made_for_you_order_confirmation method."""
|
|
162
|
+
|
|
163
|
+
def test_send_order_confirmation_basic(self):
|
|
164
|
+
"""Test basic order confirmation email."""
|
|
165
|
+
service = EmailService()
|
|
166
|
+
service.provider = Mock()
|
|
167
|
+
service.provider.send_email.return_value = True
|
|
168
|
+
|
|
169
|
+
result = service.send_made_for_you_order_confirmation(
|
|
170
|
+
to_email="customer@example.com",
|
|
171
|
+
artist="Test Artist",
|
|
172
|
+
title="Test Song",
|
|
173
|
+
job_id="test-job-123",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
assert result is True
|
|
177
|
+
service.provider.send_email.assert_called_once()
|
|
178
|
+
|
|
179
|
+
def test_send_order_confirmation_subject_includes_song(self):
|
|
180
|
+
"""Test order confirmation subject includes artist and title."""
|
|
181
|
+
service = EmailService()
|
|
182
|
+
service.provider = Mock()
|
|
183
|
+
service.provider.send_email.return_value = True
|
|
184
|
+
|
|
185
|
+
service.send_made_for_you_order_confirmation(
|
|
186
|
+
to_email="customer@example.com",
|
|
187
|
+
artist="Seether",
|
|
188
|
+
title="Tonight",
|
|
189
|
+
job_id="test-job-123",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
193
|
+
subject = call_kwargs.get('subject')
|
|
194
|
+
assert "Seether" in subject
|
|
195
|
+
assert "Tonight" in subject
|
|
196
|
+
assert "Order Confirmed" in subject
|
|
197
|
+
|
|
198
|
+
def test_send_order_confirmation_includes_customer_notes(self):
|
|
199
|
+
"""Test order confirmation includes customer notes when provided."""
|
|
200
|
+
service = EmailService()
|
|
201
|
+
service.provider = Mock()
|
|
202
|
+
service.provider.send_email.return_value = True
|
|
203
|
+
|
|
204
|
+
service.send_made_for_you_order_confirmation(
|
|
205
|
+
to_email="customer@example.com",
|
|
206
|
+
artist="Test Artist",
|
|
207
|
+
title="Test Song",
|
|
208
|
+
job_id="test-job-123",
|
|
209
|
+
notes="Wedding anniversary!",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
213
|
+
html_content = call_kwargs.get('html_content')
|
|
214
|
+
assert "Wedding anniversary!" in html_content
|
|
215
|
+
|
|
216
|
+
def test_send_order_confirmation_24_hour_promise(self):
|
|
217
|
+
"""Test order confirmation mentions 24-hour delivery."""
|
|
218
|
+
service = EmailService()
|
|
219
|
+
service.provider = Mock()
|
|
220
|
+
service.provider.send_email.return_value = True
|
|
221
|
+
|
|
222
|
+
service.send_made_for_you_order_confirmation(
|
|
223
|
+
to_email="customer@example.com",
|
|
224
|
+
artist="Test Artist",
|
|
225
|
+
title="Test Song",
|
|
226
|
+
job_id="test-job-123",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
230
|
+
html_content = call_kwargs.get('html_content')
|
|
231
|
+
text_content = call_kwargs.get('text_content')
|
|
232
|
+
# Should mention 24 hours somewhere
|
|
233
|
+
assert "24" in html_content or "24" in text_content
|
|
234
|
+
|
|
235
|
+
def test_send_order_confirmation_escapes_html(self):
|
|
236
|
+
"""Test order confirmation escapes HTML in user input."""
|
|
237
|
+
service = EmailService()
|
|
238
|
+
service.provider = Mock()
|
|
239
|
+
service.provider.send_email.return_value = True
|
|
240
|
+
|
|
241
|
+
service.send_made_for_you_order_confirmation(
|
|
242
|
+
to_email="customer@example.com",
|
|
243
|
+
artist="<script>alert('xss')</script>",
|
|
244
|
+
title="Test Song",
|
|
245
|
+
job_id="test-job-123",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
249
|
+
html_content = call_kwargs.get('html_content')
|
|
250
|
+
assert "<script>" not in html_content
|
|
251
|
+
|
|
252
|
+
def test_send_order_confirmation_no_cc(self):
|
|
253
|
+
"""Test order confirmation doesn't CC anyone."""
|
|
254
|
+
service = EmailService()
|
|
255
|
+
service.provider = Mock()
|
|
256
|
+
service.provider.send_email.return_value = True
|
|
257
|
+
|
|
258
|
+
service.send_made_for_you_order_confirmation(
|
|
259
|
+
to_email="customer@example.com",
|
|
260
|
+
artist="Test Artist",
|
|
261
|
+
title="Test Song",
|
|
262
|
+
job_id="test-job-123",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
266
|
+
assert call_kwargs.get('cc_emails') is None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestMadeForYouAdminNotificationEmail:
|
|
270
|
+
"""Tests for send_made_for_you_admin_notification method."""
|
|
271
|
+
|
|
272
|
+
def test_send_admin_notification_basic(self):
|
|
273
|
+
"""Test basic admin notification email."""
|
|
274
|
+
service = EmailService()
|
|
275
|
+
service.provider = Mock()
|
|
276
|
+
service.provider.send_email.return_value = True
|
|
277
|
+
|
|
278
|
+
result = service.send_made_for_you_admin_notification(
|
|
279
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
280
|
+
customer_email="customer@example.com",
|
|
281
|
+
artist="Test Artist",
|
|
282
|
+
title="Test Song",
|
|
283
|
+
job_id="test-job-123",
|
|
284
|
+
admin_login_token="test-token-abc123",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
assert result is True
|
|
288
|
+
service.provider.send_email.assert_called_once()
|
|
289
|
+
|
|
290
|
+
def test_send_admin_notification_subject_format(self):
|
|
291
|
+
"""Test admin notification subject format."""
|
|
292
|
+
service = EmailService()
|
|
293
|
+
service.provider = Mock()
|
|
294
|
+
service.provider.send_email.return_value = True
|
|
295
|
+
|
|
296
|
+
service.send_made_for_you_admin_notification(
|
|
297
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
298
|
+
customer_email="customer@example.com",
|
|
299
|
+
artist="Seether",
|
|
300
|
+
title="Tonight",
|
|
301
|
+
job_id="test-job-123",
|
|
302
|
+
admin_login_token="test-token-abc123",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
306
|
+
subject = call_kwargs.get('subject')
|
|
307
|
+
# New subject format: "Karaoke Order: {artist} - {title} [ID: {job_id}]"
|
|
308
|
+
assert "Karaoke Order:" in subject
|
|
309
|
+
assert "Seether" in subject
|
|
310
|
+
assert "Tonight" in subject
|
|
311
|
+
assert "[ID: test-job-123]" in subject
|
|
312
|
+
|
|
313
|
+
def test_send_admin_notification_includes_admin_token_link(self):
|
|
314
|
+
"""Test admin notification includes link with admin_token for one-click login."""
|
|
315
|
+
service = EmailService()
|
|
316
|
+
service.provider = Mock()
|
|
317
|
+
service.provider.send_email.return_value = True
|
|
318
|
+
|
|
319
|
+
service.send_made_for_you_admin_notification(
|
|
320
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
321
|
+
customer_email="customer@example.com",
|
|
322
|
+
artist="Test Artist",
|
|
323
|
+
title="Test Song",
|
|
324
|
+
job_id="abc123",
|
|
325
|
+
admin_login_token="secure-admin-token-xyz",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
329
|
+
html_content = call_kwargs.get('html_content')
|
|
330
|
+
# Should include link to /app/ with admin_token for one-click login
|
|
331
|
+
assert "admin_token=secure-admin-token-xyz" in html_content
|
|
332
|
+
assert "/app/" in html_content
|
|
333
|
+
|
|
334
|
+
def test_send_admin_notification_includes_customer_email(self):
|
|
335
|
+
"""Test admin notification shows customer email."""
|
|
336
|
+
service = EmailService()
|
|
337
|
+
service.provider = Mock()
|
|
338
|
+
service.provider.send_email.return_value = True
|
|
339
|
+
|
|
340
|
+
service.send_made_for_you_admin_notification(
|
|
341
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
342
|
+
customer_email="vip.customer@example.com",
|
|
343
|
+
artist="Test Artist",
|
|
344
|
+
title="Test Song",
|
|
345
|
+
job_id="test-job-123",
|
|
346
|
+
admin_login_token="test-token-abc123",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
350
|
+
html_content = call_kwargs.get('html_content')
|
|
351
|
+
assert "vip.customer@example.com" in html_content
|
|
352
|
+
|
|
353
|
+
def test_send_admin_notification_includes_notes(self):
|
|
354
|
+
"""Test admin notification includes customer notes."""
|
|
355
|
+
service = EmailService()
|
|
356
|
+
service.provider = Mock()
|
|
357
|
+
service.provider.send_email.return_value = True
|
|
358
|
+
|
|
359
|
+
service.send_made_for_you_admin_notification(
|
|
360
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
361
|
+
customer_email="customer@example.com",
|
|
362
|
+
artist="Test Artist",
|
|
363
|
+
title="Test Song",
|
|
364
|
+
job_id="test-job-123",
|
|
365
|
+
admin_login_token="test-token-abc123",
|
|
366
|
+
notes="Urgent - needed for wedding!",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
370
|
+
html_content = call_kwargs.get('html_content')
|
|
371
|
+
assert "Urgent - needed for wedding!" in html_content
|
|
372
|
+
|
|
373
|
+
def test_send_admin_notification_includes_audio_count(self):
|
|
374
|
+
"""Test admin notification includes audio source count."""
|
|
375
|
+
service = EmailService()
|
|
376
|
+
service.provider = Mock()
|
|
377
|
+
service.provider.send_email.return_value = True
|
|
378
|
+
|
|
379
|
+
service.send_made_for_you_admin_notification(
|
|
380
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
381
|
+
customer_email="customer@example.com",
|
|
382
|
+
artist="Test Artist",
|
|
383
|
+
title="Test Song",
|
|
384
|
+
job_id="test-job-123",
|
|
385
|
+
admin_login_token="test-token-abc123",
|
|
386
|
+
audio_source_count=5,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
390
|
+
html_content = call_kwargs.get('html_content')
|
|
391
|
+
assert "5" in html_content
|
|
392
|
+
|
|
393
|
+
def test_send_admin_notification_includes_deliver_by_eastern(self):
|
|
394
|
+
"""Test admin notification includes Deliver By deadline in Eastern Time."""
|
|
395
|
+
service = EmailService()
|
|
396
|
+
service.provider = Mock()
|
|
397
|
+
service.provider.send_email.return_value = True
|
|
398
|
+
|
|
399
|
+
service.send_made_for_you_admin_notification(
|
|
400
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
401
|
+
customer_email="customer@example.com",
|
|
402
|
+
artist="Test Artist",
|
|
403
|
+
title="Test Song",
|
|
404
|
+
job_id="test-job-123",
|
|
405
|
+
admin_login_token="test-token-abc123",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
409
|
+
html_content = call_kwargs.get('html_content')
|
|
410
|
+
text_content = call_kwargs.get('text_content')
|
|
411
|
+
# Should include "Deliver By" label and ET (Eastern Time) timezone
|
|
412
|
+
assert "Deliver By" in html_content or "Deliver By" in text_content
|
|
413
|
+
assert "ET" in html_content or "ET" in text_content
|
|
414
|
+
|
|
415
|
+
def test_send_admin_notification_escapes_html(self):
|
|
416
|
+
"""Test admin notification escapes HTML in user input."""
|
|
417
|
+
service = EmailService()
|
|
418
|
+
service.provider = Mock()
|
|
419
|
+
service.provider.send_email.return_value = True
|
|
420
|
+
|
|
421
|
+
service.send_made_for_you_admin_notification(
|
|
422
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
423
|
+
customer_email="customer@example.com",
|
|
424
|
+
artist="Test Artist",
|
|
425
|
+
title="Test Song",
|
|
426
|
+
job_id="test-job-123",
|
|
427
|
+
admin_login_token="test-token-abc123",
|
|
428
|
+
notes="<script>alert('xss')</script>",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
call_kwargs = service.provider.send_email.call_args.kwargs
|
|
432
|
+
html_content = call_kwargs.get('html_content')
|
|
433
|
+
assert "<script>" not in html_content
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# =============================================================================
|
|
437
|
+
# Video Worker Tests - Ownership Transfer
|
|
438
|
+
# =============================================================================
|
|
439
|
+
|
|
440
|
+
# Try to import video_worker - may fail due to environment dependencies
|
|
441
|
+
try:
|
|
442
|
+
from backend.workers.video_worker import _handle_made_for_you_completion
|
|
443
|
+
VIDEO_WORKER_AVAILABLE = True
|
|
444
|
+
except ImportError:
|
|
445
|
+
VIDEO_WORKER_AVAILABLE = False
|
|
446
|
+
_handle_made_for_you_completion = None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@pytest.mark.skipif(not VIDEO_WORKER_AVAILABLE, reason="video_worker import requires lyrics_transcriber")
|
|
450
|
+
class TestMadeForYouOwnershipTransfer:
|
|
451
|
+
"""Tests for ownership transfer on made-for-you job completion."""
|
|
452
|
+
|
|
453
|
+
def test_handle_made_for_you_completion_transfers_ownership(self):
|
|
454
|
+
"""Test that _handle_made_for_you_completion transfers user_email."""
|
|
455
|
+
mock_job = MagicMock()
|
|
456
|
+
mock_job.made_for_you = True
|
|
457
|
+
mock_job.customer_email = "customer@example.com"
|
|
458
|
+
|
|
459
|
+
mock_job_manager = MagicMock()
|
|
460
|
+
|
|
461
|
+
_handle_made_for_you_completion("job123", mock_job, mock_job_manager)
|
|
462
|
+
|
|
463
|
+
mock_job_manager.update_job.assert_called_once_with(
|
|
464
|
+
"job123",
|
|
465
|
+
{'user_email': 'customer@example.com'}
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def test_handle_made_for_you_completion_skips_non_made_for_you(self):
|
|
469
|
+
"""Test that ownership transfer is skipped for regular jobs."""
|
|
470
|
+
mock_job = MagicMock()
|
|
471
|
+
mock_job.made_for_you = False
|
|
472
|
+
mock_job.customer_email = "customer@example.com"
|
|
473
|
+
|
|
474
|
+
mock_job_manager = MagicMock()
|
|
475
|
+
|
|
476
|
+
_handle_made_for_you_completion("job123", mock_job, mock_job_manager)
|
|
477
|
+
|
|
478
|
+
mock_job_manager.update_job.assert_not_called()
|
|
479
|
+
|
|
480
|
+
def test_handle_made_for_you_completion_handles_missing_flag(self):
|
|
481
|
+
"""Test that ownership transfer handles jobs without made_for_you attr."""
|
|
482
|
+
mock_job = MagicMock(spec=[]) # No attributes
|
|
483
|
+
|
|
484
|
+
mock_job_manager = MagicMock()
|
|
485
|
+
|
|
486
|
+
# Should not raise, should skip gracefully
|
|
487
|
+
_handle_made_for_you_completion("job123", mock_job, mock_job_manager)
|
|
488
|
+
|
|
489
|
+
mock_job_manager.update_job.assert_not_called()
|
|
490
|
+
|
|
491
|
+
def test_handle_made_for_you_completion_handles_missing_customer_email(self):
|
|
492
|
+
"""Test that ownership transfer handles missing customer_email."""
|
|
493
|
+
mock_job = MagicMock()
|
|
494
|
+
mock_job.made_for_you = True
|
|
495
|
+
mock_job.customer_email = None
|
|
496
|
+
|
|
497
|
+
mock_job_manager = MagicMock()
|
|
498
|
+
|
|
499
|
+
# Should not raise, should skip and log warning
|
|
500
|
+
_handle_made_for_you_completion("job123", mock_job, mock_job_manager)
|
|
501
|
+
|
|
502
|
+
mock_job_manager.update_job.assert_not_called()
|
|
503
|
+
|
|
504
|
+
def test_handle_made_for_you_completion_handles_false_made_for_you(self):
|
|
505
|
+
"""Test explicit False made_for_you is not transferred."""
|
|
506
|
+
mock_job = MagicMock()
|
|
507
|
+
mock_job.made_for_you = False
|
|
508
|
+
mock_job.customer_email = "customer@example.com"
|
|
509
|
+
|
|
510
|
+
mock_job_manager = MagicMock()
|
|
511
|
+
|
|
512
|
+
_handle_made_for_you_completion("job123", mock_job, mock_job_manager)
|
|
513
|
+
|
|
514
|
+
mock_job_manager.update_job.assert_not_called()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class TestMadeForYouOwnershipTransferLogic:
|
|
518
|
+
"""
|
|
519
|
+
Tests for ownership transfer logic without requiring video_worker import.
|
|
520
|
+
|
|
521
|
+
These tests validate the logic that would be in _handle_made_for_you_completion
|
|
522
|
+
without actually importing the module.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
def test_ownership_transfer_logic_made_for_you_true(self):
|
|
526
|
+
"""Test ownership transfer logic when made_for_you is True."""
|
|
527
|
+
mock_job = MagicMock()
|
|
528
|
+
mock_job.made_for_you = True
|
|
529
|
+
mock_job.customer_email = "customer@example.com"
|
|
530
|
+
|
|
531
|
+
mock_job_manager = MagicMock()
|
|
532
|
+
|
|
533
|
+
# Replicate the logic from _handle_made_for_you_completion
|
|
534
|
+
if not getattr(mock_job, 'made_for_you', False):
|
|
535
|
+
return # Should not return early
|
|
536
|
+
|
|
537
|
+
customer_email = getattr(mock_job, 'customer_email', None)
|
|
538
|
+
if not customer_email:
|
|
539
|
+
return # Should not return early
|
|
540
|
+
|
|
541
|
+
# Transfer ownership
|
|
542
|
+
mock_job_manager.update_job("job123", {'user_email': customer_email})
|
|
543
|
+
|
|
544
|
+
mock_job_manager.update_job.assert_called_once_with(
|
|
545
|
+
"job123",
|
|
546
|
+
{'user_email': 'customer@example.com'}
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def test_ownership_transfer_logic_made_for_you_false(self):
|
|
550
|
+
"""Test ownership transfer logic when made_for_you is False."""
|
|
551
|
+
mock_job = MagicMock()
|
|
552
|
+
mock_job.made_for_you = False
|
|
553
|
+
mock_job.customer_email = "customer@example.com"
|
|
554
|
+
|
|
555
|
+
mock_job_manager = MagicMock()
|
|
556
|
+
|
|
557
|
+
# Replicate the logic
|
|
558
|
+
if not getattr(mock_job, 'made_for_you', False):
|
|
559
|
+
pass # Early return - do nothing
|
|
560
|
+
else:
|
|
561
|
+
mock_job_manager.update_job("job123", {'user_email': mock_job.customer_email})
|
|
562
|
+
|
|
563
|
+
mock_job_manager.update_job.assert_not_called()
|
|
564
|
+
|
|
565
|
+
def test_ownership_transfer_logic_missing_customer_email(self):
|
|
566
|
+
"""Test ownership transfer logic when customer_email is missing."""
|
|
567
|
+
mock_job = MagicMock()
|
|
568
|
+
mock_job.made_for_you = True
|
|
569
|
+
mock_job.customer_email = None
|
|
570
|
+
|
|
571
|
+
mock_job_manager = MagicMock()
|
|
572
|
+
|
|
573
|
+
# Replicate the logic
|
|
574
|
+
if not getattr(mock_job, 'made_for_you', False):
|
|
575
|
+
pass # Early return
|
|
576
|
+
else:
|
|
577
|
+
customer_email = getattr(mock_job, 'customer_email', None)
|
|
578
|
+
if customer_email:
|
|
579
|
+
mock_job_manager.update_job("job123", {'user_email': customer_email})
|
|
580
|
+
|
|
581
|
+
mock_job_manager.update_job.assert_not_called()
|
|
582
|
+
|
|
583
|
+
def test_ownership_transfer_logic_missing_made_for_you_attr(self):
|
|
584
|
+
"""Test ownership transfer logic when made_for_you attr doesn't exist."""
|
|
585
|
+
mock_job = MagicMock(spec=['customer_email']) # Only has customer_email
|
|
586
|
+
mock_job.customer_email = "customer@example.com"
|
|
587
|
+
|
|
588
|
+
mock_job_manager = MagicMock()
|
|
589
|
+
|
|
590
|
+
# Replicate the logic - getattr with default False handles missing attr
|
|
591
|
+
if not getattr(mock_job, 'made_for_you', False):
|
|
592
|
+
pass # Early return due to False default
|
|
593
|
+
else:
|
|
594
|
+
mock_job_manager.update_job("job123", {'user_email': mock_job.customer_email})
|
|
595
|
+
|
|
596
|
+
mock_job_manager.update_job.assert_not_called()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# =============================================================================
|
|
600
|
+
# Email Suppression Tests
|
|
601
|
+
# =============================================================================
|
|
602
|
+
|
|
603
|
+
class TestMadeForYouEmailSuppression:
|
|
604
|
+
"""Tests for email suppression on made-for-you intermediate states."""
|
|
605
|
+
|
|
606
|
+
def _create_blocking_job(self, made_for_you: bool = False) -> Job:
|
|
607
|
+
"""Create a job in a blocking state for testing."""
|
|
608
|
+
return Job(
|
|
609
|
+
job_id="test-job-123",
|
|
610
|
+
status=JobStatus.AWAITING_REVIEW,
|
|
611
|
+
created_at=datetime.now(UTC),
|
|
612
|
+
updated_at=datetime.now(UTC),
|
|
613
|
+
user_email="admin@nomadkaraoke.com",
|
|
614
|
+
artist="Test Artist",
|
|
615
|
+
title="Test Song",
|
|
616
|
+
made_for_you=made_for_you,
|
|
617
|
+
customer_email="customer@example.com" if made_for_you else None,
|
|
618
|
+
state_data={
|
|
619
|
+
'blocking_state_entered_at': datetime.now(UTC).isoformat(),
|
|
620
|
+
'blocking_action_type': 'lyrics',
|
|
621
|
+
'reminder_sent': False,
|
|
622
|
+
},
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
@pytest.fixture
|
|
626
|
+
def mock_job_manager(self):
|
|
627
|
+
"""Create a mock job manager."""
|
|
628
|
+
manager = MagicMock()
|
|
629
|
+
manager.firestore = MagicMock()
|
|
630
|
+
return manager
|
|
631
|
+
|
|
632
|
+
def test_idle_reminder_skipped_for_made_for_you_job(self, mock_job_manager):
|
|
633
|
+
"""Test that idle reminders are skipped for made-for-you jobs."""
|
|
634
|
+
# This tests the logic in internal.py's idle reminder check endpoint
|
|
635
|
+
job = self._create_blocking_job(made_for_you=True)
|
|
636
|
+
|
|
637
|
+
# The check in internal.py:
|
|
638
|
+
# if getattr(job, 'made_for_you', False):
|
|
639
|
+
# return skipped response
|
|
640
|
+
|
|
641
|
+
assert getattr(job, 'made_for_you', False) is True
|
|
642
|
+
# When this is True, the endpoint returns early without sending email
|
|
643
|
+
|
|
644
|
+
def test_idle_reminder_sent_for_regular_job(self, mock_job_manager):
|
|
645
|
+
"""Test that idle reminders ARE sent for regular jobs."""
|
|
646
|
+
job = self._create_blocking_job(made_for_you=False)
|
|
647
|
+
|
|
648
|
+
assert getattr(job, 'made_for_you', False) is False
|
|
649
|
+
# When this is False, the endpoint proceeds to send the email
|
|
650
|
+
|
|
651
|
+
def test_made_for_you_getattr_handles_missing_attribute(self):
|
|
652
|
+
"""Test getattr pattern handles jobs without made_for_you."""
|
|
653
|
+
job = Job(
|
|
654
|
+
job_id="test-job-123",
|
|
655
|
+
status=JobStatus.AWAITING_REVIEW,
|
|
656
|
+
created_at=datetime.now(UTC),
|
|
657
|
+
updated_at=datetime.now(UTC),
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Using getattr with default False
|
|
661
|
+
result = getattr(job, 'made_for_you', False)
|
|
662
|
+
# Should return False (the field exists with default False)
|
|
663
|
+
assert result is False
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# =============================================================================
|
|
667
|
+
# Integration-Style Tests
|
|
668
|
+
# =============================================================================
|
|
669
|
+
|
|
670
|
+
class TestMadeForYouIntegrationScenarios:
|
|
671
|
+
"""Integration-style tests for made-for-you scenarios."""
|
|
672
|
+
|
|
673
|
+
def test_full_made_for_you_job_creation(self):
|
|
674
|
+
"""Test creating a job with full made-for-you configuration."""
|
|
675
|
+
# Simulate the job creation from webhook handler
|
|
676
|
+
job_create = JobCreate(
|
|
677
|
+
artist="Seether",
|
|
678
|
+
title="Tonight",
|
|
679
|
+
user_email="admin@nomadkaraoke.com", # Admin owns during processing
|
|
680
|
+
made_for_you=True,
|
|
681
|
+
customer_email="customer@example.com",
|
|
682
|
+
customer_notes="Anniversary song!",
|
|
683
|
+
# Distribution settings
|
|
684
|
+
enable_youtube_upload=True,
|
|
685
|
+
dropbox_path="/Production/Ready To Upload",
|
|
686
|
+
gdrive_folder_id="1ABC123",
|
|
687
|
+
brand_prefix="NOMAD",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
assert job_create.user_email == "admin@nomadkaraoke.com"
|
|
691
|
+
assert job_create.customer_email == "customer@example.com"
|
|
692
|
+
assert job_create.made_for_you is True
|
|
693
|
+
assert job_create.enable_youtube_upload is True
|
|
694
|
+
assert job_create.dropbox_path == "/Production/Ready To Upload"
|
|
695
|
+
|
|
696
|
+
def test_job_visibility_logic(self):
|
|
697
|
+
"""Test job visibility logic for made-for-you jobs."""
|
|
698
|
+
# During processing: job owned by admin
|
|
699
|
+
job_processing = Job(
|
|
700
|
+
job_id="test123",
|
|
701
|
+
status=JobStatus.PROCESSING,
|
|
702
|
+
created_at=datetime.now(UTC),
|
|
703
|
+
updated_at=datetime.now(UTC),
|
|
704
|
+
user_email="admin@nomadkaraoke.com",
|
|
705
|
+
customer_email="customer@example.com",
|
|
706
|
+
made_for_you=True,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Job should NOT be visible to customer (user_email != customer_email)
|
|
710
|
+
assert job_processing.user_email != job_processing.customer_email
|
|
711
|
+
|
|
712
|
+
# After completion: ownership transferred
|
|
713
|
+
job_complete = Job(
|
|
714
|
+
job_id="test123",
|
|
715
|
+
status=JobStatus.COMPLETE,
|
|
716
|
+
created_at=datetime.now(UTC),
|
|
717
|
+
updated_at=datetime.now(UTC),
|
|
718
|
+
user_email="customer@example.com", # Transferred
|
|
719
|
+
customer_email="customer@example.com",
|
|
720
|
+
made_for_you=True,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# Job should now be visible to customer
|
|
724
|
+
assert job_complete.user_email == job_complete.customer_email
|
|
725
|
+
|
|
726
|
+
def test_email_flow_simulation(self):
|
|
727
|
+
"""Test the expected email flow for made-for-you orders."""
|
|
728
|
+
service = EmailService()
|
|
729
|
+
service.provider = Mock()
|
|
730
|
+
service.provider.send_email.return_value = True
|
|
731
|
+
|
|
732
|
+
# Step 1: Order confirmation to customer
|
|
733
|
+
service.send_made_for_you_order_confirmation(
|
|
734
|
+
to_email="customer@example.com",
|
|
735
|
+
artist="Test Artist",
|
|
736
|
+
title="Test Song",
|
|
737
|
+
job_id="job123",
|
|
738
|
+
notes="Test notes",
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# Step 2: Admin notification
|
|
742
|
+
service.send_made_for_you_admin_notification(
|
|
743
|
+
to_email="madeforyou@nomadkaraoke.com",
|
|
744
|
+
customer_email="customer@example.com",
|
|
745
|
+
artist="Test Artist",
|
|
746
|
+
title="Test Song",
|
|
747
|
+
job_id="job123",
|
|
748
|
+
admin_login_token="test-admin-token-xyz",
|
|
749
|
+
audio_source_count=3,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
# Verify both emails were sent
|
|
753
|
+
assert service.provider.send_email.call_count == 2
|
|
754
|
+
|
|
755
|
+
# First call should be to customer
|
|
756
|
+
first_call = service.provider.send_email.call_args_list[0]
|
|
757
|
+
assert first_call.kwargs['to_email'] == "customer@example.com"
|
|
758
|
+
|
|
759
|
+
# Second call should be to admin
|
|
760
|
+
second_call = service.provider.send_email.call_args_list[1]
|
|
761
|
+
assert second_call.kwargs['to_email'] == "madeforyou@nomadkaraoke.com"
|
|
762
|
+
|
|
763
|
+
def test_awaiting_audio_selection_state(self):
|
|
764
|
+
"""Test that made-for-you jobs use AWAITING_AUDIO_SELECTION state."""
|
|
765
|
+
job = Job(
|
|
766
|
+
job_id="test123",
|
|
767
|
+
status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
768
|
+
created_at=datetime.now(UTC),
|
|
769
|
+
updated_at=datetime.now(UTC),
|
|
770
|
+
user_email="admin@nomadkaraoke.com",
|
|
771
|
+
customer_email="customer@example.com",
|
|
772
|
+
made_for_you=True,
|
|
773
|
+
state_data={
|
|
774
|
+
'audio_search_results': [
|
|
775
|
+
{'source': 'spotify', 'url': 'https://...'},
|
|
776
|
+
{'source': 'deezer', 'url': 'https://...'},
|
|
777
|
+
],
|
|
778
|
+
},
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
assert job.status == JobStatus.AWAITING_AUDIO_SELECTION
|
|
782
|
+
assert job.made_for_you is True
|
|
783
|
+
assert 'audio_search_results' in job.state_data
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class TestMadeForYouEdgeCases:
|
|
787
|
+
"""Edge case tests for made-for-you functionality."""
|
|
788
|
+
|
|
789
|
+
def test_empty_customer_notes(self):
|
|
790
|
+
"""Test job with empty customer notes."""
|
|
791
|
+
job = Job(
|
|
792
|
+
job_id="test123",
|
|
793
|
+
status=JobStatus.PENDING,
|
|
794
|
+
created_at=datetime.now(UTC),
|
|
795
|
+
updated_at=datetime.now(UTC),
|
|
796
|
+
made_for_you=True,
|
|
797
|
+
customer_email="customer@example.com",
|
|
798
|
+
customer_notes="", # Empty string
|
|
799
|
+
)
|
|
800
|
+
assert job.customer_notes == ""
|
|
801
|
+
|
|
802
|
+
def test_unicode_in_customer_notes(self):
|
|
803
|
+
"""Test job with unicode in customer notes."""
|
|
804
|
+
job = Job(
|
|
805
|
+
job_id="test123",
|
|
806
|
+
status=JobStatus.PENDING,
|
|
807
|
+
created_at=datetime.now(UTC),
|
|
808
|
+
updated_at=datetime.now(UTC),
|
|
809
|
+
made_for_you=True,
|
|
810
|
+
customer_email="customer@example.com",
|
|
811
|
+
customer_notes="Anniversary song! 🎉🎂",
|
|
812
|
+
)
|
|
813
|
+
assert "🎉" in job.customer_notes
|
|
814
|
+
|
|
815
|
+
def test_special_characters_in_email(self):
|
|
816
|
+
"""Test job with special characters in customer email."""
|
|
817
|
+
job = Job(
|
|
818
|
+
job_id="test123",
|
|
819
|
+
status=JobStatus.PENDING,
|
|
820
|
+
created_at=datetime.now(UTC),
|
|
821
|
+
updated_at=datetime.now(UTC),
|
|
822
|
+
made_for_you=True,
|
|
823
|
+
customer_email="customer+special@example.com",
|
|
824
|
+
)
|
|
825
|
+
assert job.customer_email == "customer+special@example.com"
|
|
826
|
+
|
|
827
|
+
def test_long_customer_notes(self):
|
|
828
|
+
"""Test job with very long customer notes."""
|
|
829
|
+
long_notes = "A" * 5000
|
|
830
|
+
job = Job(
|
|
831
|
+
job_id="test123",
|
|
832
|
+
status=JobStatus.PENDING,
|
|
833
|
+
created_at=datetime.now(UTC),
|
|
834
|
+
updated_at=datetime.now(UTC),
|
|
835
|
+
made_for_you=True,
|
|
836
|
+
customer_email="customer@example.com",
|
|
837
|
+
customer_notes=long_notes,
|
|
838
|
+
)
|
|
839
|
+
assert len(job.customer_notes) == 5000
|
|
840
|
+
|
|
841
|
+
def test_made_for_you_false_explicitly(self):
|
|
842
|
+
"""Test regular job with explicit made_for_you=False."""
|
|
843
|
+
job = Job(
|
|
844
|
+
job_id="test123",
|
|
845
|
+
status=JobStatus.PENDING,
|
|
846
|
+
created_at=datetime.now(UTC),
|
|
847
|
+
updated_at=datetime.now(UTC),
|
|
848
|
+
made_for_you=False,
|
|
849
|
+
user_email="regular@example.com",
|
|
850
|
+
)
|
|
851
|
+
assert job.made_for_you is False
|
|
852
|
+
assert job.customer_email is None
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# =============================================================================
|
|
856
|
+
# Webhook Handler Tests - _handle_made_for_you_order
|
|
857
|
+
# =============================================================================
|
|
858
|
+
|
|
859
|
+
class TestHandleMadeForYouOrder:
|
|
860
|
+
"""
|
|
861
|
+
Comprehensive tests for the _handle_made_for_you_order webhook handler.
|
|
862
|
+
|
|
863
|
+
This function processes Stripe checkout completions for made-for-you orders.
|
|
864
|
+
It must:
|
|
865
|
+
1. Create a job with made_for_you=True
|
|
866
|
+
2. Set user_email to admin email (admin owns during processing)
|
|
867
|
+
3. Set customer_email for final delivery
|
|
868
|
+
4. Set customer_notes from order
|
|
869
|
+
5. Set auto_download=False to pause at audio selection
|
|
870
|
+
6. Perform audio search to populate options
|
|
871
|
+
7. Transition job to AWAITING_AUDIO_SELECTION state
|
|
872
|
+
8. Send admin notification email with audio selection link
|
|
873
|
+
9. Send customer order confirmation email
|
|
874
|
+
"""
|
|
875
|
+
|
|
876
|
+
@pytest.fixture
|
|
877
|
+
def mock_job_manager(self):
|
|
878
|
+
"""Create mock JobManager that captures job creation params."""
|
|
879
|
+
manager = MagicMock()
|
|
880
|
+
# Return a mock Job object when create_job is called
|
|
881
|
+
mock_job = MagicMock()
|
|
882
|
+
mock_job.job_id = "test-job-123"
|
|
883
|
+
manager.create_job.return_value = mock_job
|
|
884
|
+
return manager
|
|
885
|
+
|
|
886
|
+
@pytest.fixture
|
|
887
|
+
def mock_worker_service(self):
|
|
888
|
+
"""Create mock worker service."""
|
|
889
|
+
service = MagicMock()
|
|
890
|
+
service.trigger_audio_worker = AsyncMock()
|
|
891
|
+
service.trigger_lyrics_worker = AsyncMock()
|
|
892
|
+
return service
|
|
893
|
+
|
|
894
|
+
@pytest.fixture
|
|
895
|
+
def mock_audio_search_service(self):
|
|
896
|
+
"""Create mock audio search service with results."""
|
|
897
|
+
service = MagicMock()
|
|
898
|
+
|
|
899
|
+
# Mock search results
|
|
900
|
+
mock_result = MagicMock()
|
|
901
|
+
mock_result.to_dict.return_value = {
|
|
902
|
+
'provider': 'RED',
|
|
903
|
+
'artist': 'Test Artist',
|
|
904
|
+
'title': 'Test Album',
|
|
905
|
+
'quality': 'FLAC 16bit CD',
|
|
906
|
+
'is_lossless': True,
|
|
907
|
+
}
|
|
908
|
+
service.search.return_value = [mock_result, mock_result, mock_result]
|
|
909
|
+
service.last_remote_search_id = "search_abc123"
|
|
910
|
+
return service
|
|
911
|
+
|
|
912
|
+
@pytest.fixture
|
|
913
|
+
def mock_storage_service(self):
|
|
914
|
+
"""Create mock storage service."""
|
|
915
|
+
return MagicMock()
|
|
916
|
+
|
|
917
|
+
@pytest.fixture
|
|
918
|
+
def mock_email_service(self):
|
|
919
|
+
"""Create mock email service."""
|
|
920
|
+
service = MagicMock()
|
|
921
|
+
service.send_email.return_value = True
|
|
922
|
+
service.send_made_for_you_order_confirmation.return_value = True
|
|
923
|
+
service.send_made_for_you_admin_notification.return_value = True
|
|
924
|
+
return service
|
|
925
|
+
|
|
926
|
+
@pytest.fixture
|
|
927
|
+
def mock_user_service(self):
|
|
928
|
+
"""Create mock user service."""
|
|
929
|
+
service = MagicMock()
|
|
930
|
+
service._mark_stripe_session_processed.return_value = None
|
|
931
|
+
# Mock create_admin_login_token to return a MagicMock with .token attribute
|
|
932
|
+
mock_admin_login = MagicMock()
|
|
933
|
+
mock_admin_login.token = "test-admin-login-token-123"
|
|
934
|
+
service.create_admin_login_token.return_value = mock_admin_login
|
|
935
|
+
return service
|
|
936
|
+
|
|
937
|
+
@pytest.fixture
|
|
938
|
+
def mock_theme_service(self):
|
|
939
|
+
"""Create mock theme service."""
|
|
940
|
+
service = MagicMock()
|
|
941
|
+
service.get_default_theme_id.return_value = "nomad"
|
|
942
|
+
return service
|
|
943
|
+
|
|
944
|
+
@pytest.fixture
|
|
945
|
+
def order_metadata(self):
|
|
946
|
+
"""Standard order metadata from Stripe session."""
|
|
947
|
+
return {
|
|
948
|
+
"order_type": "made_for_you",
|
|
949
|
+
"customer_email": "customer@example.com",
|
|
950
|
+
"artist": "Avril Lavigne",
|
|
951
|
+
"title": "Complicated",
|
|
952
|
+
"source_type": "search",
|
|
953
|
+
"notes": "Please make sure the lyrics are perfect!",
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
@pytest.mark.asyncio
|
|
957
|
+
async def test_job_created_with_made_for_you_flag(
|
|
958
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
959
|
+
mock_theme_service, order_metadata
|
|
960
|
+
):
|
|
961
|
+
"""
|
|
962
|
+
CRITICAL TEST: Job must be created with made_for_you=True.
|
|
963
|
+
|
|
964
|
+
This flag is essential for:
|
|
965
|
+
- Ownership transfer on completion
|
|
966
|
+
- Email suppression for intermediate states
|
|
967
|
+
- Identifying made-for-you jobs in admin UI
|
|
968
|
+
"""
|
|
969
|
+
from backend.api.routes.users import _handle_made_for_you_order, ADMIN_EMAIL
|
|
970
|
+
|
|
971
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
972
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
973
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
974
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
975
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
976
|
+
|
|
977
|
+
# Mock audio search to raise NoResultsError so we skip download logic
|
|
978
|
+
from backend.services.audio_search_service import NoResultsError
|
|
979
|
+
mock_audio_service = MagicMock()
|
|
980
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
981
|
+
mock_get_audio.return_value = mock_audio_service
|
|
982
|
+
|
|
983
|
+
await _handle_made_for_you_order(
|
|
984
|
+
session_id="sess_123",
|
|
985
|
+
metadata=order_metadata,
|
|
986
|
+
user_service=mock_user_service,
|
|
987
|
+
email_service=mock_email_service,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
# Verify job was created
|
|
991
|
+
mock_job_manager.create_job.assert_called_once()
|
|
992
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
993
|
+
|
|
994
|
+
# CRITICAL ASSERTION: made_for_you must be True
|
|
995
|
+
assert job_create_arg.made_for_you is True, \
|
|
996
|
+
"Job must be created with made_for_you=True for made-for-you orders"
|
|
997
|
+
|
|
998
|
+
@pytest.mark.asyncio
|
|
999
|
+
async def test_job_owned_by_admin_during_processing(
|
|
1000
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1001
|
+
mock_theme_service, order_metadata
|
|
1002
|
+
):
|
|
1003
|
+
"""
|
|
1004
|
+
CRITICAL TEST: Job user_email must be admin email, not customer email.
|
|
1005
|
+
|
|
1006
|
+
During processing, admin owns the job to:
|
|
1007
|
+
- See it in their job list
|
|
1008
|
+
- Handle intermediate review steps
|
|
1009
|
+
- Customer only gets access after completion
|
|
1010
|
+
"""
|
|
1011
|
+
from backend.api.routes.users import _handle_made_for_you_order, ADMIN_EMAIL
|
|
1012
|
+
|
|
1013
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1014
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1015
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1016
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1017
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1018
|
+
|
|
1019
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1020
|
+
mock_audio_service = MagicMock()
|
|
1021
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1022
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1023
|
+
|
|
1024
|
+
await _handle_made_for_you_order(
|
|
1025
|
+
session_id="sess_123",
|
|
1026
|
+
metadata=order_metadata,
|
|
1027
|
+
user_service=mock_user_service,
|
|
1028
|
+
email_service=mock_email_service,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1032
|
+
|
|
1033
|
+
# CRITICAL ASSERTION: admin owns the job during processing
|
|
1034
|
+
assert job_create_arg.user_email == ADMIN_EMAIL, \
|
|
1035
|
+
f"Job user_email must be {ADMIN_EMAIL}, not customer email"
|
|
1036
|
+
assert job_create_arg.user_email != order_metadata["customer_email"], \
|
|
1037
|
+
"Job user_email must NOT be customer email during processing"
|
|
1038
|
+
|
|
1039
|
+
@pytest.mark.asyncio
|
|
1040
|
+
async def test_customer_email_stored_for_delivery(
|
|
1041
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1042
|
+
mock_theme_service, order_metadata
|
|
1043
|
+
):
|
|
1044
|
+
"""
|
|
1045
|
+
CRITICAL TEST: customer_email must be stored for final delivery.
|
|
1046
|
+
|
|
1047
|
+
The customer_email field is used for:
|
|
1048
|
+
- Ownership transfer on completion
|
|
1049
|
+
- Sending completion email with download links
|
|
1050
|
+
"""
|
|
1051
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1052
|
+
|
|
1053
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1054
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1055
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1056
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1057
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1058
|
+
|
|
1059
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1060
|
+
mock_audio_service = MagicMock()
|
|
1061
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1062
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1063
|
+
|
|
1064
|
+
await _handle_made_for_you_order(
|
|
1065
|
+
session_id="sess_123",
|
|
1066
|
+
metadata=order_metadata,
|
|
1067
|
+
user_service=mock_user_service,
|
|
1068
|
+
email_service=mock_email_service,
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1072
|
+
|
|
1073
|
+
# CRITICAL ASSERTION: customer_email stored for delivery
|
|
1074
|
+
assert job_create_arg.customer_email == order_metadata["customer_email"], \
|
|
1075
|
+
"Job must store customer_email for final delivery"
|
|
1076
|
+
|
|
1077
|
+
@pytest.mark.asyncio
|
|
1078
|
+
async def test_customer_notes_preserved(
|
|
1079
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1080
|
+
mock_theme_service, order_metadata
|
|
1081
|
+
):
|
|
1082
|
+
"""
|
|
1083
|
+
Customer notes from order must be stored on job for admin reference.
|
|
1084
|
+
"""
|
|
1085
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1086
|
+
|
|
1087
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1088
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1089
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1090
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1091
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1092
|
+
|
|
1093
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1094
|
+
mock_audio_service = MagicMock()
|
|
1095
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1096
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1097
|
+
|
|
1098
|
+
await _handle_made_for_you_order(
|
|
1099
|
+
session_id="sess_123",
|
|
1100
|
+
metadata=order_metadata,
|
|
1101
|
+
user_service=mock_user_service,
|
|
1102
|
+
email_service=mock_email_service,
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1106
|
+
|
|
1107
|
+
assert job_create_arg.customer_notes == order_metadata["notes"], \
|
|
1108
|
+
"Job must store customer notes from order"
|
|
1109
|
+
|
|
1110
|
+
@pytest.mark.asyncio
|
|
1111
|
+
async def test_auto_download_disabled_for_admin_selection(
|
|
1112
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1113
|
+
mock_theme_service, order_metadata
|
|
1114
|
+
):
|
|
1115
|
+
"""
|
|
1116
|
+
CRITICAL TEST: auto_download must be False to pause for admin selection.
|
|
1117
|
+
|
|
1118
|
+
The made-for-you flow requires admin to select the audio source.
|
|
1119
|
+
Setting auto_download=True would bypass this, selecting automatically.
|
|
1120
|
+
"""
|
|
1121
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1122
|
+
|
|
1123
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1124
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1125
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1126
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1127
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1128
|
+
|
|
1129
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1130
|
+
mock_audio_service = MagicMock()
|
|
1131
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1132
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1133
|
+
|
|
1134
|
+
await _handle_made_for_you_order(
|
|
1135
|
+
session_id="sess_123",
|
|
1136
|
+
metadata=order_metadata,
|
|
1137
|
+
user_service=mock_user_service,
|
|
1138
|
+
email_service=mock_email_service,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1142
|
+
|
|
1143
|
+
# CRITICAL ASSERTION: auto_download must be False
|
|
1144
|
+
assert job_create_arg.auto_download is False, \
|
|
1145
|
+
"auto_download must be False to allow admin audio selection"
|
|
1146
|
+
|
|
1147
|
+
@pytest.mark.asyncio
|
|
1148
|
+
async def test_job_transitions_to_awaiting_audio_selection_with_results(
|
|
1149
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1150
|
+
mock_theme_service, mock_audio_search_service, order_metadata
|
|
1151
|
+
):
|
|
1152
|
+
"""
|
|
1153
|
+
When audio search returns results, job should pause at AWAITING_AUDIO_SELECTION.
|
|
1154
|
+
"""
|
|
1155
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1156
|
+
from backend.models.job import JobStatus
|
|
1157
|
+
|
|
1158
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1159
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1160
|
+
patch('backend.services.audio_search_service.get_audio_search_service', return_value=mock_audio_search_service), \
|
|
1161
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1162
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1163
|
+
|
|
1164
|
+
await _handle_made_for_you_order(
|
|
1165
|
+
session_id="sess_123",
|
|
1166
|
+
metadata=order_metadata,
|
|
1167
|
+
user_service=mock_user_service,
|
|
1168
|
+
email_service=mock_email_service,
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
# Check that job transitioned to AWAITING_AUDIO_SELECTION
|
|
1172
|
+
transition_calls = mock_job_manager.transition_to_state.call_args_list
|
|
1173
|
+
|
|
1174
|
+
# Should have at least one transition to AWAITING_AUDIO_SELECTION
|
|
1175
|
+
awaiting_selection_calls = [
|
|
1176
|
+
call for call in transition_calls
|
|
1177
|
+
if call.kwargs.get('new_status') == JobStatus.AWAITING_AUDIO_SELECTION
|
|
1178
|
+
or (len(call.args) > 1 and call.args[1] == JobStatus.AWAITING_AUDIO_SELECTION)
|
|
1179
|
+
]
|
|
1180
|
+
|
|
1181
|
+
assert len(awaiting_selection_calls) >= 1, \
|
|
1182
|
+
"Job should transition to AWAITING_AUDIO_SELECTION for admin to select audio"
|
|
1183
|
+
|
|
1184
|
+
@pytest.mark.asyncio
|
|
1185
|
+
async def test_audio_search_results_stored_in_state_data(
|
|
1186
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1187
|
+
mock_theme_service, mock_audio_search_service, order_metadata
|
|
1188
|
+
):
|
|
1189
|
+
"""
|
|
1190
|
+
Audio search results must be stored so admin can view options.
|
|
1191
|
+
"""
|
|
1192
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1193
|
+
|
|
1194
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1195
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1196
|
+
patch('backend.services.audio_search_service.get_audio_search_service', return_value=mock_audio_search_service), \
|
|
1197
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1198
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1199
|
+
|
|
1200
|
+
await _handle_made_for_you_order(
|
|
1201
|
+
session_id="sess_123",
|
|
1202
|
+
metadata=order_metadata,
|
|
1203
|
+
user_service=mock_user_service,
|
|
1204
|
+
email_service=mock_email_service,
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
# Check update_job was called with audio_search_results
|
|
1208
|
+
update_calls = mock_job_manager.update_job.call_args_list
|
|
1209
|
+
|
|
1210
|
+
# Should have stored search results somewhere in the update calls
|
|
1211
|
+
all_call_str = str(update_calls)
|
|
1212
|
+
assert 'audio_search_results' in all_call_str or 'state_data' in all_call_str, \
|
|
1213
|
+
"Audio search results should be stored in job state_data"
|
|
1214
|
+
|
|
1215
|
+
@pytest.mark.asyncio
|
|
1216
|
+
async def test_admin_notification_email_sent(
|
|
1217
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1218
|
+
mock_theme_service, order_metadata
|
|
1219
|
+
):
|
|
1220
|
+
"""
|
|
1221
|
+
Admin must receive email notification about new made-for-you order.
|
|
1222
|
+
"""
|
|
1223
|
+
from backend.api.routes.users import _handle_made_for_you_order, ADMIN_EMAIL
|
|
1224
|
+
|
|
1225
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1226
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1227
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1228
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1229
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1230
|
+
|
|
1231
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1232
|
+
mock_audio_service = MagicMock()
|
|
1233
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1234
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1235
|
+
|
|
1236
|
+
await _handle_made_for_you_order(
|
|
1237
|
+
session_id="sess_123",
|
|
1238
|
+
metadata=order_metadata,
|
|
1239
|
+
user_service=mock_user_service,
|
|
1240
|
+
email_service=mock_email_service,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
# Check email_service.send_made_for_you_admin_notification was called
|
|
1244
|
+
mock_email_service.send_made_for_you_admin_notification.assert_called_once()
|
|
1245
|
+
call_kwargs = mock_email_service.send_made_for_you_admin_notification.call_args.kwargs
|
|
1246
|
+
assert call_kwargs['to_email'] == ADMIN_EMAIL, \
|
|
1247
|
+
f"Admin at {ADMIN_EMAIL} must receive notification email"
|
|
1248
|
+
|
|
1249
|
+
@pytest.mark.asyncio
|
|
1250
|
+
async def test_customer_confirmation_email_sent(
|
|
1251
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1252
|
+
mock_theme_service, order_metadata
|
|
1253
|
+
):
|
|
1254
|
+
"""
|
|
1255
|
+
Customer must receive order confirmation email.
|
|
1256
|
+
"""
|
|
1257
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1258
|
+
|
|
1259
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1260
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1261
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1262
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1263
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1264
|
+
|
|
1265
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1266
|
+
mock_audio_service = MagicMock()
|
|
1267
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1268
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1269
|
+
|
|
1270
|
+
await _handle_made_for_you_order(
|
|
1271
|
+
session_id="sess_123",
|
|
1272
|
+
metadata=order_metadata,
|
|
1273
|
+
user_service=mock_user_service,
|
|
1274
|
+
email_service=mock_email_service,
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
# Check email_service.send_made_for_you_order_confirmation was called
|
|
1278
|
+
mock_email_service.send_made_for_you_order_confirmation.assert_called_once()
|
|
1279
|
+
call_kwargs = mock_email_service.send_made_for_you_order_confirmation.call_args.kwargs
|
|
1280
|
+
assert call_kwargs['to_email'] == order_metadata["customer_email"], \
|
|
1281
|
+
"Customer must receive order confirmation email"
|
|
1282
|
+
assert call_kwargs['job_id'] == "test-job-123", \
|
|
1283
|
+
"Customer email must include job ID (order reference)"
|
|
1284
|
+
|
|
1285
|
+
@pytest.mark.asyncio
|
|
1286
|
+
async def test_admin_email_includes_admin_login_token(
|
|
1287
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1288
|
+
mock_theme_service, order_metadata
|
|
1289
|
+
):
|
|
1290
|
+
"""
|
|
1291
|
+
Admin notification email must include admin_login_token for one-click login.
|
|
1292
|
+
"""
|
|
1293
|
+
from backend.api.routes.users import _handle_made_for_you_order, ADMIN_EMAIL
|
|
1294
|
+
|
|
1295
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1296
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1297
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1298
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1299
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1300
|
+
|
|
1301
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1302
|
+
mock_audio_service = MagicMock()
|
|
1303
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1304
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1305
|
+
|
|
1306
|
+
await _handle_made_for_you_order(
|
|
1307
|
+
session_id="sess_123",
|
|
1308
|
+
metadata=order_metadata,
|
|
1309
|
+
user_service=mock_user_service,
|
|
1310
|
+
email_service=mock_email_service,
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
# Verify create_admin_login_token was called to generate the token
|
|
1314
|
+
mock_user_service.create_admin_login_token.assert_called_once_with(
|
|
1315
|
+
email=ADMIN_EMAIL,
|
|
1316
|
+
expiry_hours=24,
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# Check email_service.send_made_for_you_admin_notification was called with admin_login_token
|
|
1320
|
+
mock_email_service.send_made_for_you_admin_notification.assert_called_once()
|
|
1321
|
+
call_kwargs = mock_email_service.send_made_for_you_admin_notification.call_args.kwargs
|
|
1322
|
+
|
|
1323
|
+
# Verify job_id and admin_login_token are passed
|
|
1324
|
+
assert call_kwargs.get('job_id') == "test-job-123", \
|
|
1325
|
+
"Admin email must include job ID for reference"
|
|
1326
|
+
assert call_kwargs.get('admin_login_token') == "test-admin-login-token-123", \
|
|
1327
|
+
"Admin email must include admin_login_token for one-click login"
|
|
1328
|
+
|
|
1329
|
+
@pytest.mark.asyncio
|
|
1330
|
+
async def test_youtube_url_order_skips_search(
|
|
1331
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1332
|
+
mock_theme_service
|
|
1333
|
+
):
|
|
1334
|
+
"""
|
|
1335
|
+
Orders with YouTube URL should trigger workers directly, not search.
|
|
1336
|
+
"""
|
|
1337
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1338
|
+
|
|
1339
|
+
youtube_metadata = {
|
|
1340
|
+
"order_type": "made_for_you",
|
|
1341
|
+
"customer_email": "customer@example.com",
|
|
1342
|
+
"artist": "Test Artist",
|
|
1343
|
+
"title": "Test Song",
|
|
1344
|
+
"source_type": "youtube",
|
|
1345
|
+
"youtube_url": "https://youtube.com/watch?v=abc123",
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
mock_worker_service = MagicMock()
|
|
1349
|
+
mock_worker_service.trigger_audio_worker = AsyncMock()
|
|
1350
|
+
mock_worker_service.trigger_lyrics_worker = AsyncMock()
|
|
1351
|
+
|
|
1352
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1353
|
+
patch('backend.services.worker_service.get_worker_service', return_value=mock_worker_service), \
|
|
1354
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1355
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1356
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1357
|
+
|
|
1358
|
+
await _handle_made_for_you_order(
|
|
1359
|
+
session_id="sess_123",
|
|
1360
|
+
metadata=youtube_metadata,
|
|
1361
|
+
user_service=mock_user_service,
|
|
1362
|
+
email_service=mock_email_service,
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1366
|
+
|
|
1367
|
+
# URL should be set
|
|
1368
|
+
assert job_create_arg.url == youtube_metadata["youtube_url"]
|
|
1369
|
+
|
|
1370
|
+
# Workers should be triggered directly
|
|
1371
|
+
mock_worker_service.trigger_audio_worker.assert_called_once()
|
|
1372
|
+
mock_worker_service.trigger_lyrics_worker.assert_called_once()
|
|
1373
|
+
|
|
1374
|
+
@pytest.mark.asyncio
|
|
1375
|
+
async def test_stripe_session_marked_processed(
|
|
1376
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1377
|
+
mock_theme_service, order_metadata
|
|
1378
|
+
):
|
|
1379
|
+
"""
|
|
1380
|
+
Stripe session must be marked as processed for idempotency.
|
|
1381
|
+
"""
|
|
1382
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1383
|
+
|
|
1384
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1385
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1386
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1387
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1388
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1389
|
+
|
|
1390
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1391
|
+
mock_audio_service = MagicMock()
|
|
1392
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1393
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1394
|
+
|
|
1395
|
+
await _handle_made_for_you_order(
|
|
1396
|
+
session_id="sess_123",
|
|
1397
|
+
metadata=order_metadata,
|
|
1398
|
+
user_service=mock_user_service,
|
|
1399
|
+
email_service=mock_email_service,
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
mock_user_service._mark_stripe_session_processed.assert_called_once()
|
|
1403
|
+
|
|
1404
|
+
@pytest.mark.asyncio
|
|
1405
|
+
async def test_default_theme_applied(
|
|
1406
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1407
|
+
mock_theme_service, order_metadata
|
|
1408
|
+
):
|
|
1409
|
+
"""
|
|
1410
|
+
Default theme (nomad) should be applied to made-for-you jobs.
|
|
1411
|
+
"""
|
|
1412
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1413
|
+
|
|
1414
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1415
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1416
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1417
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1418
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1419
|
+
|
|
1420
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1421
|
+
mock_audio_service = MagicMock()
|
|
1422
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1423
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1424
|
+
|
|
1425
|
+
await _handle_made_for_you_order(
|
|
1426
|
+
session_id="sess_123",
|
|
1427
|
+
metadata=order_metadata,
|
|
1428
|
+
user_service=mock_user_service,
|
|
1429
|
+
email_service=mock_email_service,
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1433
|
+
|
|
1434
|
+
assert job_create_arg.theme_id == "nomad", \
|
|
1435
|
+
"Default nomad theme should be applied to made-for-you jobs"
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
class TestMadeForYouDistributionSettings:
|
|
1439
|
+
"""
|
|
1440
|
+
Tests for distribution settings on made-for-you jobs.
|
|
1441
|
+
|
|
1442
|
+
Made-for-you jobs should have distribution defaults applied:
|
|
1443
|
+
- YouTube upload enabled
|
|
1444
|
+
- Dropbox path set
|
|
1445
|
+
- Google Drive folder ID set
|
|
1446
|
+
- Brand prefix set
|
|
1447
|
+
|
|
1448
|
+
These ensure the finished video is automatically distributed.
|
|
1449
|
+
"""
|
|
1450
|
+
|
|
1451
|
+
@pytest.fixture
|
|
1452
|
+
def mock_job_manager(self):
|
|
1453
|
+
"""Create mock JobManager."""
|
|
1454
|
+
manager = MagicMock()
|
|
1455
|
+
mock_job = MagicMock()
|
|
1456
|
+
mock_job.job_id = "test-job-123"
|
|
1457
|
+
manager.create_job.return_value = mock_job
|
|
1458
|
+
return manager
|
|
1459
|
+
|
|
1460
|
+
@pytest.fixture
|
|
1461
|
+
def mock_email_service(self):
|
|
1462
|
+
"""Create mock email service."""
|
|
1463
|
+
service = MagicMock()
|
|
1464
|
+
service.send_email.return_value = True
|
|
1465
|
+
return service
|
|
1466
|
+
|
|
1467
|
+
@pytest.fixture
|
|
1468
|
+
def mock_user_service(self):
|
|
1469
|
+
"""Create mock user service."""
|
|
1470
|
+
service = MagicMock()
|
|
1471
|
+
service._mark_stripe_session_processed.return_value = None
|
|
1472
|
+
# Mock create_admin_login_token to return a MagicMock with .token attribute
|
|
1473
|
+
mock_admin_login = MagicMock()
|
|
1474
|
+
mock_admin_login.token = "test-admin-login-token-123"
|
|
1475
|
+
service.create_admin_login_token.return_value = mock_admin_login
|
|
1476
|
+
return service
|
|
1477
|
+
|
|
1478
|
+
@pytest.fixture
|
|
1479
|
+
def mock_theme_service(self):
|
|
1480
|
+
"""Create mock theme service."""
|
|
1481
|
+
service = MagicMock()
|
|
1482
|
+
service.get_default_theme_id.return_value = "nomad"
|
|
1483
|
+
return service
|
|
1484
|
+
|
|
1485
|
+
@pytest.fixture
|
|
1486
|
+
def order_metadata(self):
|
|
1487
|
+
"""Standard order metadata."""
|
|
1488
|
+
return {
|
|
1489
|
+
"order_type": "made_for_you",
|
|
1490
|
+
"customer_email": "customer@example.com",
|
|
1491
|
+
"artist": "Test Artist",
|
|
1492
|
+
"title": "Test Song",
|
|
1493
|
+
"source_type": "search",
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
@pytest.fixture
|
|
1497
|
+
def mock_settings(self):
|
|
1498
|
+
"""Mock settings with distribution defaults."""
|
|
1499
|
+
settings = MagicMock()
|
|
1500
|
+
settings.default_enable_youtube_upload = True
|
|
1501
|
+
settings.default_dropbox_path = "/Production/Ready To Upload"
|
|
1502
|
+
settings.default_gdrive_folder_id = "1ABC123xyz"
|
|
1503
|
+
settings.default_brand_prefix = "NOMAD"
|
|
1504
|
+
settings.default_discord_webhook_url = None
|
|
1505
|
+
settings.default_youtube_description = None
|
|
1506
|
+
return settings
|
|
1507
|
+
|
|
1508
|
+
@pytest.mark.asyncio
|
|
1509
|
+
async def test_youtube_upload_enabled_by_default(
|
|
1510
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1511
|
+
mock_theme_service, mock_settings, order_metadata
|
|
1512
|
+
):
|
|
1513
|
+
"""
|
|
1514
|
+
CRITICAL TEST: Made-for-you jobs should have YouTube upload enabled.
|
|
1515
|
+
|
|
1516
|
+
This ensures the finished video is uploaded to YouTube automatically.
|
|
1517
|
+
"""
|
|
1518
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1519
|
+
|
|
1520
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1521
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1522
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1523
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1524
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service), \
|
|
1525
|
+
patch('backend.config.get_settings', return_value=mock_settings):
|
|
1526
|
+
|
|
1527
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1528
|
+
mock_audio_service = MagicMock()
|
|
1529
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1530
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1531
|
+
|
|
1532
|
+
await _handle_made_for_you_order(
|
|
1533
|
+
session_id="sess_123",
|
|
1534
|
+
metadata=order_metadata,
|
|
1535
|
+
user_service=mock_user_service,
|
|
1536
|
+
email_service=mock_email_service,
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1540
|
+
|
|
1541
|
+
assert job_create_arg.enable_youtube_upload is True, \
|
|
1542
|
+
"Made-for-you jobs should have YouTube upload enabled by default"
|
|
1543
|
+
|
|
1544
|
+
@pytest.mark.asyncio
|
|
1545
|
+
async def test_dropbox_path_set_by_default(
|
|
1546
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1547
|
+
mock_theme_service, mock_settings, order_metadata
|
|
1548
|
+
):
|
|
1549
|
+
"""
|
|
1550
|
+
CRITICAL TEST: Made-for-you jobs should have Dropbox path set.
|
|
1551
|
+
|
|
1552
|
+
This ensures the finished video is synced to Dropbox automatically.
|
|
1553
|
+
"""
|
|
1554
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1555
|
+
|
|
1556
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1557
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1558
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1559
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1560
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service), \
|
|
1561
|
+
patch('backend.config.get_settings', return_value=mock_settings):
|
|
1562
|
+
|
|
1563
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1564
|
+
mock_audio_service = MagicMock()
|
|
1565
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1566
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1567
|
+
|
|
1568
|
+
await _handle_made_for_you_order(
|
|
1569
|
+
session_id="sess_123",
|
|
1570
|
+
metadata=order_metadata,
|
|
1571
|
+
user_service=mock_user_service,
|
|
1572
|
+
email_service=mock_email_service,
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1576
|
+
|
|
1577
|
+
assert job_create_arg.dropbox_path == "/Production/Ready To Upload", \
|
|
1578
|
+
"Made-for-you jobs should have Dropbox path set from defaults"
|
|
1579
|
+
|
|
1580
|
+
@pytest.mark.asyncio
|
|
1581
|
+
async def test_gdrive_folder_id_set_by_default(
|
|
1582
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1583
|
+
mock_theme_service, mock_settings, order_metadata
|
|
1584
|
+
):
|
|
1585
|
+
"""
|
|
1586
|
+
CRITICAL TEST: Made-for-you jobs should have Google Drive folder ID set.
|
|
1587
|
+
|
|
1588
|
+
This ensures the finished video is uploaded to Google Drive automatically.
|
|
1589
|
+
"""
|
|
1590
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1591
|
+
|
|
1592
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1593
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1594
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1595
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1596
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service), \
|
|
1597
|
+
patch('backend.config.get_settings', return_value=mock_settings):
|
|
1598
|
+
|
|
1599
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1600
|
+
mock_audio_service = MagicMock()
|
|
1601
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1602
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1603
|
+
|
|
1604
|
+
await _handle_made_for_you_order(
|
|
1605
|
+
session_id="sess_123",
|
|
1606
|
+
metadata=order_metadata,
|
|
1607
|
+
user_service=mock_user_service,
|
|
1608
|
+
email_service=mock_email_service,
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1612
|
+
|
|
1613
|
+
assert job_create_arg.gdrive_folder_id == "1ABC123xyz", \
|
|
1614
|
+
"Made-for-you jobs should have Google Drive folder ID set from defaults"
|
|
1615
|
+
|
|
1616
|
+
@pytest.mark.asyncio
|
|
1617
|
+
async def test_brand_prefix_set_by_default(
|
|
1618
|
+
self, mock_job_manager, mock_email_service, mock_user_service,
|
|
1619
|
+
mock_theme_service, mock_settings, order_metadata
|
|
1620
|
+
):
|
|
1621
|
+
"""
|
|
1622
|
+
Brand prefix should be set from server defaults.
|
|
1623
|
+
"""
|
|
1624
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1625
|
+
|
|
1626
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1627
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1628
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1629
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1630
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service), \
|
|
1631
|
+
patch('backend.config.get_settings', return_value=mock_settings):
|
|
1632
|
+
|
|
1633
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1634
|
+
mock_audio_service = MagicMock()
|
|
1635
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1636
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1637
|
+
|
|
1638
|
+
await _handle_made_for_you_order(
|
|
1639
|
+
session_id="sess_123",
|
|
1640
|
+
metadata=order_metadata,
|
|
1641
|
+
user_service=mock_user_service,
|
|
1642
|
+
email_service=mock_email_service,
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
job_create_arg = mock_job_manager.create_job.call_args[0][0]
|
|
1646
|
+
|
|
1647
|
+
assert job_create_arg.brand_prefix == "NOMAD", \
|
|
1648
|
+
"Made-for-you jobs should have brand prefix set from defaults"
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
class TestMadeForYouJobCreateContract:
|
|
1652
|
+
"""
|
|
1653
|
+
Contract tests to ensure JobCreate for made-for-you orders has correct shape.
|
|
1654
|
+
|
|
1655
|
+
These tests document the expected contract between the webhook handler
|
|
1656
|
+
and the job creation system.
|
|
1657
|
+
"""
|
|
1658
|
+
|
|
1659
|
+
def test_made_for_you_job_create_has_all_required_fields(self):
|
|
1660
|
+
"""
|
|
1661
|
+
A properly configured made-for-you JobCreate must have all these fields.
|
|
1662
|
+
"""
|
|
1663
|
+
# This is what the webhook handler SHOULD create
|
|
1664
|
+
job_create = JobCreate(
|
|
1665
|
+
artist="Test Artist",
|
|
1666
|
+
title="Test Song",
|
|
1667
|
+
user_email="admin@nomadkaraoke.com", # Admin, not customer
|
|
1668
|
+
theme_id="nomad",
|
|
1669
|
+
made_for_you=True, # Flag for MFY
|
|
1670
|
+
customer_email="customer@example.com", # For delivery
|
|
1671
|
+
customer_notes="Special request", # Customer notes
|
|
1672
|
+
auto_download=False, # Pause for selection
|
|
1673
|
+
audio_search_artist="Test Artist",
|
|
1674
|
+
audio_search_title="Test Song",
|
|
1675
|
+
non_interactive=False, # Admin reviews
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
# Verify all critical fields
|
|
1679
|
+
assert job_create.made_for_you is True
|
|
1680
|
+
assert job_create.user_email == "admin@nomadkaraoke.com"
|
|
1681
|
+
assert job_create.customer_email == "customer@example.com"
|
|
1682
|
+
assert job_create.customer_notes == "Special request"
|
|
1683
|
+
assert job_create.auto_download is False
|
|
1684
|
+
assert job_create.non_interactive is False
|
|
1685
|
+
|
|
1686
|
+
def test_job_create_customer_not_owner(self):
|
|
1687
|
+
"""
|
|
1688
|
+
Verify customer_email is distinct from user_email (owner).
|
|
1689
|
+
"""
|
|
1690
|
+
job_create = JobCreate(
|
|
1691
|
+
artist="Test",
|
|
1692
|
+
title="Test",
|
|
1693
|
+
user_email="admin@nomadkaraoke.com",
|
|
1694
|
+
customer_email="customer@example.com",
|
|
1695
|
+
made_for_you=True,
|
|
1696
|
+
)
|
|
1697
|
+
|
|
1698
|
+
assert job_create.user_email != job_create.customer_email, \
|
|
1699
|
+
"Customer should not own job during processing"
|
|
1700
|
+
|
|
1701
|
+
def test_made_for_you_with_distribution_defaults(self):
|
|
1702
|
+
"""
|
|
1703
|
+
Made-for-you jobs should have distribution defaults from server settings.
|
|
1704
|
+
"""
|
|
1705
|
+
job_create = JobCreate(
|
|
1706
|
+
artist="Test",
|
|
1707
|
+
title="Test",
|
|
1708
|
+
user_email="admin@nomadkaraoke.com",
|
|
1709
|
+
made_for_you=True,
|
|
1710
|
+
customer_email="customer@example.com",
|
|
1711
|
+
# Distribution settings that should be applied
|
|
1712
|
+
enable_youtube_upload=True,
|
|
1713
|
+
dropbox_path="/Production/Ready To Upload",
|
|
1714
|
+
gdrive_folder_id="1ABC123xyz",
|
|
1715
|
+
brand_prefix="NOMAD",
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
# These should be configurable for made-for-you orders
|
|
1719
|
+
assert hasattr(job_create, 'enable_youtube_upload')
|
|
1720
|
+
assert hasattr(job_create, 'dropbox_path')
|
|
1721
|
+
assert hasattr(job_create, 'gdrive_folder_id')
|
|
1722
|
+
assert hasattr(job_create, 'brand_prefix')
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
# =============================================================================
|
|
1726
|
+
# Webhook Handler Email Template Tests
|
|
1727
|
+
# =============================================================================
|
|
1728
|
+
|
|
1729
|
+
class TestMadeForYouWebhookEmailTemplates:
|
|
1730
|
+
"""
|
|
1731
|
+
CRITICAL: Tests that webhook handler uses proper email template methods.
|
|
1732
|
+
|
|
1733
|
+
Bug context (2026-01-09): The webhook handler was using generic send_email()
|
|
1734
|
+
with inline HTML instead of the professional template methods:
|
|
1735
|
+
- send_made_for_you_order_confirmation()
|
|
1736
|
+
- send_made_for_you_admin_notification()
|
|
1737
|
+
|
|
1738
|
+
This resulted in emails not being sent in the expected format and made
|
|
1739
|
+
debugging harder since the inline HTML was different from the templates.
|
|
1740
|
+
"""
|
|
1741
|
+
|
|
1742
|
+
@pytest.fixture
|
|
1743
|
+
def mock_job_manager(self):
|
|
1744
|
+
"""Create mock JobManager that captures job creation params."""
|
|
1745
|
+
manager = MagicMock()
|
|
1746
|
+
mock_job = MagicMock()
|
|
1747
|
+
mock_job.job_id = "test-job-123"
|
|
1748
|
+
manager.create_job.return_value = mock_job
|
|
1749
|
+
return manager
|
|
1750
|
+
|
|
1751
|
+
@pytest.fixture
|
|
1752
|
+
def mock_user_service(self):
|
|
1753
|
+
"""Create mock user service."""
|
|
1754
|
+
service = MagicMock()
|
|
1755
|
+
service._mark_stripe_session_processed.return_value = None
|
|
1756
|
+
# Mock create_admin_login_token to return a MagicMock with .token attribute
|
|
1757
|
+
mock_admin_login = MagicMock()
|
|
1758
|
+
mock_admin_login.token = "test-admin-login-token-123"
|
|
1759
|
+
service.create_admin_login_token.return_value = mock_admin_login
|
|
1760
|
+
return service
|
|
1761
|
+
|
|
1762
|
+
@pytest.fixture
|
|
1763
|
+
def mock_theme_service(self):
|
|
1764
|
+
"""Create mock theme service."""
|
|
1765
|
+
service = MagicMock()
|
|
1766
|
+
service.get_default_theme_id.return_value = "nomad"
|
|
1767
|
+
return service
|
|
1768
|
+
|
|
1769
|
+
@pytest.fixture
|
|
1770
|
+
def order_metadata(self):
|
|
1771
|
+
"""Standard order metadata from Stripe session."""
|
|
1772
|
+
return {
|
|
1773
|
+
"order_type": "made_for_you",
|
|
1774
|
+
"customer_email": "customer@example.com",
|
|
1775
|
+
"artist": "Avril Lavigne",
|
|
1776
|
+
"title": "Complicated",
|
|
1777
|
+
"source_type": "search",
|
|
1778
|
+
"notes": "Please make sure the lyrics are perfect!",
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
@pytest.mark.asyncio
|
|
1782
|
+
async def test_webhook_uses_order_confirmation_template(
|
|
1783
|
+
self, mock_job_manager, mock_user_service, mock_theme_service, order_metadata
|
|
1784
|
+
):
|
|
1785
|
+
"""
|
|
1786
|
+
CRITICAL: Webhook must use send_made_for_you_order_confirmation() template.
|
|
1787
|
+
|
|
1788
|
+
The template method provides:
|
|
1789
|
+
- Professional formatting
|
|
1790
|
+
- Consistent branding
|
|
1791
|
+
- Proper email structure with header/footer
|
|
1792
|
+
|
|
1793
|
+
Using generic send_email() with inline HTML is error-prone and inconsistent.
|
|
1794
|
+
"""
|
|
1795
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1796
|
+
|
|
1797
|
+
mock_email_service = MagicMock()
|
|
1798
|
+
mock_email_service.send_made_for_you_order_confirmation.return_value = True
|
|
1799
|
+
mock_email_service.send_made_for_you_admin_notification.return_value = True
|
|
1800
|
+
|
|
1801
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1802
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1803
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1804
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1805
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1806
|
+
|
|
1807
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1808
|
+
mock_audio_service = MagicMock()
|
|
1809
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1810
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1811
|
+
|
|
1812
|
+
await _handle_made_for_you_order(
|
|
1813
|
+
session_id="sess_123",
|
|
1814
|
+
metadata=order_metadata,
|
|
1815
|
+
user_service=mock_user_service,
|
|
1816
|
+
email_service=mock_email_service,
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
# CRITICAL ASSERTION: Must use the template method, not generic send_email
|
|
1820
|
+
mock_email_service.send_made_for_you_order_confirmation.assert_called_once()
|
|
1821
|
+
|
|
1822
|
+
# Verify the template was called with correct parameters
|
|
1823
|
+
call_kwargs = mock_email_service.send_made_for_you_order_confirmation.call_args.kwargs
|
|
1824
|
+
assert call_kwargs['to_email'] == order_metadata["customer_email"]
|
|
1825
|
+
assert call_kwargs['artist'] == order_metadata["artist"]
|
|
1826
|
+
assert call_kwargs['title'] == order_metadata["title"]
|
|
1827
|
+
assert call_kwargs['job_id'] == "test-job-123"
|
|
1828
|
+
assert call_kwargs['notes'] == order_metadata["notes"]
|
|
1829
|
+
|
|
1830
|
+
@pytest.mark.asyncio
|
|
1831
|
+
async def test_webhook_uses_admin_notification_template(
|
|
1832
|
+
self, mock_job_manager, mock_user_service, mock_theme_service, order_metadata
|
|
1833
|
+
):
|
|
1834
|
+
"""
|
|
1835
|
+
CRITICAL: Webhook must use send_made_for_you_admin_notification() template.
|
|
1836
|
+
|
|
1837
|
+
The template method provides:
|
|
1838
|
+
- Deadline in EST
|
|
1839
|
+
- Link with admin_token for one-click login
|
|
1840
|
+
- Audio source count
|
|
1841
|
+
- Professional formatting
|
|
1842
|
+
"""
|
|
1843
|
+
from backend.api.routes.users import _handle_made_for_you_order, ADMIN_EMAIL
|
|
1844
|
+
|
|
1845
|
+
mock_email_service = MagicMock()
|
|
1846
|
+
mock_email_service.send_made_for_you_order_confirmation.return_value = True
|
|
1847
|
+
mock_email_service.send_made_for_you_admin_notification.return_value = True
|
|
1848
|
+
|
|
1849
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1850
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1851
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1852
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1853
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1854
|
+
|
|
1855
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1856
|
+
mock_audio_service = MagicMock()
|
|
1857
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1858
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1859
|
+
|
|
1860
|
+
await _handle_made_for_you_order(
|
|
1861
|
+
session_id="sess_123",
|
|
1862
|
+
metadata=order_metadata,
|
|
1863
|
+
user_service=mock_user_service,
|
|
1864
|
+
email_service=mock_email_service,
|
|
1865
|
+
)
|
|
1866
|
+
|
|
1867
|
+
# CRITICAL ASSERTION: Must use the template method, not generic send_email
|
|
1868
|
+
mock_email_service.send_made_for_you_admin_notification.assert_called_once()
|
|
1869
|
+
|
|
1870
|
+
# Verify the template was called with correct parameters
|
|
1871
|
+
call_kwargs = mock_email_service.send_made_for_you_admin_notification.call_args.kwargs
|
|
1872
|
+
assert call_kwargs['to_email'] == ADMIN_EMAIL
|
|
1873
|
+
assert call_kwargs['customer_email'] == order_metadata["customer_email"]
|
|
1874
|
+
assert call_kwargs['artist'] == order_metadata["artist"]
|
|
1875
|
+
assert call_kwargs['title'] == order_metadata["title"]
|
|
1876
|
+
assert call_kwargs['job_id'] == "test-job-123"
|
|
1877
|
+
assert call_kwargs['admin_login_token'] == "test-admin-login-token-123"
|
|
1878
|
+
|
|
1879
|
+
@pytest.mark.asyncio
|
|
1880
|
+
async def test_webhook_does_not_use_generic_send_email_for_notifications(
|
|
1881
|
+
self, mock_job_manager, mock_user_service, mock_theme_service, order_metadata
|
|
1882
|
+
):
|
|
1883
|
+
"""
|
|
1884
|
+
CRITICAL: Webhook must NOT use generic send_email() for made-for-you notifications.
|
|
1885
|
+
|
|
1886
|
+
The generic send_email() method was being used with inline HTML, which:
|
|
1887
|
+
- Has no consistent styling
|
|
1888
|
+
- Is harder to maintain
|
|
1889
|
+
- May not match the professional templates
|
|
1890
|
+
|
|
1891
|
+
This test ensures send_email is NOT called for the order/admin notifications.
|
|
1892
|
+
"""
|
|
1893
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1894
|
+
|
|
1895
|
+
mock_email_service = MagicMock()
|
|
1896
|
+
mock_email_service.send_made_for_you_order_confirmation.return_value = True
|
|
1897
|
+
mock_email_service.send_made_for_you_admin_notification.return_value = True
|
|
1898
|
+
|
|
1899
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1900
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1901
|
+
patch('backend.services.audio_search_service.get_audio_search_service') as mock_get_audio, \
|
|
1902
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1903
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1904
|
+
|
|
1905
|
+
from backend.services.audio_search_service import NoResultsError
|
|
1906
|
+
mock_audio_service = MagicMock()
|
|
1907
|
+
mock_audio_service.search.side_effect = NoResultsError("No results")
|
|
1908
|
+
mock_get_audio.return_value = mock_audio_service
|
|
1909
|
+
|
|
1910
|
+
await _handle_made_for_you_order(
|
|
1911
|
+
session_id="sess_123",
|
|
1912
|
+
metadata=order_metadata,
|
|
1913
|
+
user_service=mock_user_service,
|
|
1914
|
+
email_service=mock_email_service,
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
# CRITICAL ASSERTION: generic send_email should NOT be used
|
|
1918
|
+
# for made-for-you order/admin notifications
|
|
1919
|
+
mock_email_service.send_email.assert_not_called()
|
|
1920
|
+
|
|
1921
|
+
@pytest.mark.asyncio
|
|
1922
|
+
async def test_admin_notification_includes_audio_source_count(
|
|
1923
|
+
self, mock_job_manager, mock_user_service, mock_theme_service, order_metadata
|
|
1924
|
+
):
|
|
1925
|
+
"""
|
|
1926
|
+
Admin notification should include count of audio sources found.
|
|
1927
|
+
|
|
1928
|
+
This helps admin prioritize orders and know what to expect.
|
|
1929
|
+
"""
|
|
1930
|
+
from backend.api.routes.users import _handle_made_for_you_order
|
|
1931
|
+
|
|
1932
|
+
mock_email_service = MagicMock()
|
|
1933
|
+
mock_email_service.send_made_for_you_order_confirmation.return_value = True
|
|
1934
|
+
mock_email_service.send_made_for_you_admin_notification.return_value = True
|
|
1935
|
+
|
|
1936
|
+
# Mock audio search that returns results
|
|
1937
|
+
mock_audio_service = MagicMock()
|
|
1938
|
+
mock_result = MagicMock()
|
|
1939
|
+
mock_result.to_dict.return_value = {'provider': 'RED', 'title': 'Test'}
|
|
1940
|
+
mock_audio_service.search.return_value = [mock_result, mock_result, mock_result] # 3 results
|
|
1941
|
+
mock_audio_service.last_remote_search_id = "search_123"
|
|
1942
|
+
|
|
1943
|
+
with patch('backend.services.job_manager.JobManager', return_value=mock_job_manager), \
|
|
1944
|
+
patch('backend.services.worker_service.get_worker_service'), \
|
|
1945
|
+
patch('backend.services.audio_search_service.get_audio_search_service', return_value=mock_audio_service), \
|
|
1946
|
+
patch('backend.services.storage_service.StorageService'), \
|
|
1947
|
+
patch('backend.api.routes.users.get_theme_service', return_value=mock_theme_service):
|
|
1948
|
+
|
|
1949
|
+
await _handle_made_for_you_order(
|
|
1950
|
+
session_id="sess_123",
|
|
1951
|
+
metadata=order_metadata,
|
|
1952
|
+
user_service=mock_user_service,
|
|
1953
|
+
email_service=mock_email_service,
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
# Verify audio_source_count is passed to admin notification
|
|
1957
|
+
call_kwargs = mock_email_service.send_made_for_you_admin_notification.call_args.kwargs
|
|
1958
|
+
assert call_kwargs['audio_source_count'] == 3
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
# =============================================================================
|
|
1962
|
+
# Job Manager Completion Email Tests
|
|
1963
|
+
# =============================================================================
|
|
1964
|
+
|
|
1965
|
+
class TestJobManagerCompletionEmail:
|
|
1966
|
+
"""
|
|
1967
|
+
Tests for job manager's _schedule_completion_email handling of made-for-you jobs.
|
|
1968
|
+
|
|
1969
|
+
These tests verify that:
|
|
1970
|
+
1. Made-for-you jobs send completion email to customer_email (not admin)
|
|
1971
|
+
2. Ownership is transferred from admin to customer on completion
|
|
1972
|
+
3. Regular jobs still send to user_email as before
|
|
1973
|
+
"""
|
|
1974
|
+
|
|
1975
|
+
@pytest.fixture
|
|
1976
|
+
def job_manager(self):
|
|
1977
|
+
"""Create JobManager with mocked dependencies."""
|
|
1978
|
+
with patch('backend.services.job_manager.FirestoreService'), \
|
|
1979
|
+
patch('backend.services.job_manager.StorageService'):
|
|
1980
|
+
from backend.services.job_manager import JobManager
|
|
1981
|
+
manager = JobManager()
|
|
1982
|
+
manager.firestore = MagicMock()
|
|
1983
|
+
return manager
|
|
1984
|
+
|
|
1985
|
+
@pytest.fixture
|
|
1986
|
+
def made_for_you_job(self):
|
|
1987
|
+
"""Create a completed made-for-you job."""
|
|
1988
|
+
return Job(
|
|
1989
|
+
job_id="made-for-you-job-123",
|
|
1990
|
+
status=JobStatus.COMPLETE,
|
|
1991
|
+
created_at=datetime.now(UTC),
|
|
1992
|
+
updated_at=datetime.now(UTC),
|
|
1993
|
+
user_email="madeforyou@nomadkaraoke.com", # Admin owns during processing
|
|
1994
|
+
artist="Avril Lavigne",
|
|
1995
|
+
title="Complicated",
|
|
1996
|
+
made_for_you=True,
|
|
1997
|
+
customer_email="customer@example.com", # Actual customer
|
|
1998
|
+
state_data={
|
|
1999
|
+
'youtube_url': 'https://youtube.com/watch?v=123',
|
|
2000
|
+
'dropbox_link': 'https://dropbox.com/folder/123',
|
|
2001
|
+
'brand_code': 'NOMAD-1234',
|
|
2002
|
+
},
|
|
2003
|
+
)
|
|
2004
|
+
|
|
2005
|
+
@pytest.fixture
|
|
2006
|
+
def regular_job(self):
|
|
2007
|
+
"""Create a completed regular job (not made-for-you)."""
|
|
2008
|
+
return Job(
|
|
2009
|
+
job_id="regular-job-456",
|
|
2010
|
+
status=JobStatus.COMPLETE,
|
|
2011
|
+
created_at=datetime.now(UTC),
|
|
2012
|
+
updated_at=datetime.now(UTC),
|
|
2013
|
+
user_email="user@example.com",
|
|
2014
|
+
artist="Test Artist",
|
|
2015
|
+
title="Test Song",
|
|
2016
|
+
made_for_you=False,
|
|
2017
|
+
customer_email=None,
|
|
2018
|
+
state_data={
|
|
2019
|
+
'youtube_url': 'https://youtube.com/watch?v=456',
|
|
2020
|
+
'dropbox_link': 'https://dropbox.com/folder/456',
|
|
2021
|
+
'brand_code': 'NOMAD-5678',
|
|
2022
|
+
},
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
def test_made_for_you_completion_sends_to_customer(self, job_manager, made_for_you_job):
|
|
2026
|
+
"""Made-for-you completion email goes to customer, not admin."""
|
|
2027
|
+
mock_notification_service = MagicMock()
|
|
2028
|
+
|
|
2029
|
+
with patch('backend.services.job_notification_service.get_job_notification_service', return_value=mock_notification_service):
|
|
2030
|
+
job_manager._schedule_completion_email(made_for_you_job)
|
|
2031
|
+
|
|
2032
|
+
# Give async task time to be created
|
|
2033
|
+
import time
|
|
2034
|
+
time.sleep(0.1)
|
|
2035
|
+
|
|
2036
|
+
# Verify ownership was transferred
|
|
2037
|
+
job_manager.firestore.update_job.assert_called_with(
|
|
2038
|
+
"made-for-you-job-123",
|
|
2039
|
+
{'user_email': 'customer@example.com'}
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
def test_made_for_you_completion_transfers_ownership(self, job_manager, made_for_you_job):
|
|
2043
|
+
"""Made-for-you job ownership is transferred to customer on completion."""
|
|
2044
|
+
mock_notification_service = MagicMock()
|
|
2045
|
+
|
|
2046
|
+
with patch('backend.services.job_notification_service.get_job_notification_service', return_value=mock_notification_service):
|
|
2047
|
+
job_manager._schedule_completion_email(made_for_you_job)
|
|
2048
|
+
|
|
2049
|
+
# Verify update_job was called with the customer email
|
|
2050
|
+
job_manager.firestore.update_job.assert_called_once()
|
|
2051
|
+
call_args = job_manager.firestore.update_job.call_args
|
|
2052
|
+
assert call_args[0][0] == "made-for-you-job-123"
|
|
2053
|
+
assert call_args[0][1] == {'user_email': 'customer@example.com'}
|
|
2054
|
+
|
|
2055
|
+
def test_regular_job_completion_sends_to_user(self, job_manager, regular_job):
|
|
2056
|
+
"""Regular job completion email goes to user_email."""
|
|
2057
|
+
mock_notification_service = MagicMock()
|
|
2058
|
+
|
|
2059
|
+
with patch('backend.services.job_notification_service.get_job_notification_service', return_value=mock_notification_service):
|
|
2060
|
+
job_manager._schedule_completion_email(regular_job)
|
|
2061
|
+
|
|
2062
|
+
# Verify NO ownership transfer for regular jobs
|
|
2063
|
+
job_manager.firestore.update_job.assert_not_called()
|
|
2064
|
+
|
|
2065
|
+
def test_made_for_you_without_customer_email_skips_transfer(self, job_manager):
|
|
2066
|
+
"""Made-for-you job without customer_email doesn't crash."""
|
|
2067
|
+
job_without_customer = Job(
|
|
2068
|
+
job_id="no-customer-job",
|
|
2069
|
+
status=JobStatus.COMPLETE,
|
|
2070
|
+
created_at=datetime.now(UTC),
|
|
2071
|
+
updated_at=datetime.now(UTC),
|
|
2072
|
+
user_email="madeforyou@nomadkaraoke.com",
|
|
2073
|
+
artist="Test",
|
|
2074
|
+
title="Test",
|
|
2075
|
+
made_for_you=True,
|
|
2076
|
+
customer_email=None, # Missing customer email
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
mock_notification_service = MagicMock()
|
|
2080
|
+
|
|
2081
|
+
with patch('backend.services.job_notification_service.get_job_notification_service', return_value=mock_notification_service):
|
|
2082
|
+
# Should not raise
|
|
2083
|
+
job_manager._schedule_completion_email(job_without_customer)
|
|
2084
|
+
|
|
2085
|
+
# No ownership transfer without customer_email
|
|
2086
|
+
job_manager.firestore.update_job.assert_not_called()
|