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.
Files changed (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {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()