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,326 @@
1
+ """
2
+ Unit tests for admin job update endpoint.
3
+
4
+ Tests the PATCH /api/admin/jobs/{job_id} endpoint that allows admins
5
+ to update editable job fields.
6
+ """
7
+ import pytest
8
+ from unittest.mock import Mock, patch
9
+ from fastapi.testclient import TestClient
10
+ from fastapi import FastAPI
11
+
12
+ from backend.api.routes.admin import router
13
+ from backend.api.dependencies import require_admin
14
+ from backend.models.job import Job, JobStatus
15
+
16
+
17
+ # Create a test app with the admin router
18
+ app = FastAPI()
19
+ app.include_router(router, prefix="/api")
20
+
21
+
22
+ def get_mock_admin():
23
+ """Override for require_admin dependency."""
24
+ from backend.api.dependencies import AuthResult, UserType
25
+ return AuthResult(
26
+ is_valid=True,
27
+ user_type=UserType.ADMIN,
28
+ remaining_uses=999,
29
+ message="Admin authenticated",
30
+ user_email="admin@example.com",
31
+ is_admin=True,
32
+ )
33
+
34
+
35
+ # Override the require_admin dependency
36
+ app.dependency_overrides[require_admin] = get_mock_admin
37
+
38
+
39
+ @pytest.fixture
40
+ def client():
41
+ """Create a test client."""
42
+ return TestClient(app)
43
+
44
+
45
+ @pytest.fixture
46
+ def mock_job():
47
+ """Create a mock job for testing."""
48
+ job = Mock(spec=Job)
49
+ job.job_id = "test-job-123"
50
+ job.user_email = "user@example.com"
51
+ job.artist = "Original Artist"
52
+ job.title = "Original Title"
53
+ job.status = JobStatus.AWAITING_REVIEW
54
+ job.theme_id = "nomad"
55
+ job.enable_cdg = False
56
+ job.enable_txt = False
57
+ job.enable_youtube_upload = True
58
+ job.customer_email = None
59
+ job.customer_notes = None
60
+ job.brand_prefix = None
61
+ job.non_interactive = False
62
+ job.prep_only = False
63
+ job.discord_webhook_url = None
64
+ job.youtube_description = None
65
+ job.youtube_description_template = None
66
+ job.file_urls = {}
67
+ job.state_data = {}
68
+ job.created_at = "2026-01-09T10:00:00Z"
69
+ job.updated_at = "2026-01-09T10:30:00Z"
70
+ return job
71
+
72
+
73
+ class TestUpdateJob:
74
+ """Tests for PATCH /api/admin/jobs/{job_id} endpoint."""
75
+
76
+ def test_update_artist_field(self, client, mock_job):
77
+ """Test updating the artist field."""
78
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
79
+ mock_jm = Mock()
80
+ mock_jm.get_job.return_value = mock_job
81
+ mock_jm.update_job.return_value = True
82
+ mock_jm_class.return_value = mock_jm
83
+
84
+ response = client.patch(
85
+ "/api/admin/jobs/test-job-123",
86
+ json={"artist": "New Artist"},
87
+ headers={"Authorization": "Bearer admin-token"}
88
+ )
89
+
90
+ assert response.status_code == 200
91
+ data = response.json()
92
+ assert data["status"] == "success"
93
+ assert data["job_id"] == "test-job-123"
94
+ assert "artist" in data["updated_fields"]
95
+
96
+ # Verify update_job was called with correct args
97
+ mock_jm.update_job.assert_called_once()
98
+ call_args = mock_jm.update_job.call_args
99
+ assert call_args[0][0] == "test-job-123"
100
+ assert call_args[0][1]["artist"] == "New Artist"
101
+
102
+ def test_update_multiple_fields(self, client, mock_job):
103
+ """Test updating multiple fields at once."""
104
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
105
+ mock_jm = Mock()
106
+ mock_jm.get_job.return_value = mock_job
107
+ mock_jm.update_job.return_value = True
108
+ mock_jm_class.return_value = mock_jm
109
+
110
+ response = client.patch(
111
+ "/api/admin/jobs/test-job-123",
112
+ json={
113
+ "artist": "New Artist",
114
+ "title": "New Title",
115
+ "theme_id": "default",
116
+ "enable_cdg": True,
117
+ },
118
+ )
119
+
120
+ assert response.status_code == 200
121
+ data = response.json()
122
+ assert len(data["updated_fields"]) == 4
123
+ assert "artist" in data["updated_fields"]
124
+ assert "title" in data["updated_fields"]
125
+ assert "theme_id" in data["updated_fields"]
126
+ assert "enable_cdg" in data["updated_fields"]
127
+
128
+ def test_update_user_email(self, client, mock_job):
129
+ """Test updating user_email field."""
130
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
131
+ mock_jm = Mock()
132
+ mock_jm.get_job.return_value = mock_job
133
+ mock_jm.update_job.return_value = True
134
+ mock_jm_class.return_value = mock_jm
135
+
136
+ response = client.patch(
137
+ "/api/admin/jobs/test-job-123",
138
+ json={"user_email": "newuser@example.com"},
139
+ )
140
+
141
+ assert response.status_code == 200
142
+ call_args = mock_jm.update_job.call_args
143
+ assert call_args[0][1]["user_email"] == "newuser@example.com"
144
+
145
+ def test_update_boolean_fields(self, client, mock_job):
146
+ """Test updating boolean fields."""
147
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
148
+ mock_jm = Mock()
149
+ mock_jm.get_job.return_value = mock_job
150
+ mock_jm.update_job.return_value = True
151
+ mock_jm_class.return_value = mock_jm
152
+
153
+ response = client.patch(
154
+ "/api/admin/jobs/test-job-123",
155
+ json={
156
+ "enable_cdg": True,
157
+ "enable_txt": True,
158
+ "enable_youtube_upload": False,
159
+ "non_interactive": True,
160
+ "prep_only": True,
161
+ },
162
+ )
163
+
164
+ assert response.status_code == 200
165
+ call_args = mock_jm.update_job.call_args
166
+ assert call_args[0][1]["enable_cdg"] is True
167
+ assert call_args[0][1]["enable_txt"] is True
168
+ assert call_args[0][1]["enable_youtube_upload"] is False
169
+
170
+ def test_rejects_non_editable_job_id(self, client, mock_job):
171
+ """Test that job_id cannot be updated."""
172
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
173
+ mock_jm = Mock()
174
+ mock_jm.get_job.return_value = mock_job
175
+ mock_jm_class.return_value = mock_jm
176
+
177
+ response = client.patch(
178
+ "/api/admin/jobs/test-job-123",
179
+ json={"job_id": "new-job-id"},
180
+ )
181
+
182
+ assert response.status_code == 400
183
+ assert "not editable" in response.json()["detail"].lower()
184
+
185
+ def test_rejects_non_editable_created_at(self, client, mock_job):
186
+ """Test that created_at cannot be updated."""
187
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
188
+ mock_jm = Mock()
189
+ mock_jm.get_job.return_value = mock_job
190
+ mock_jm_class.return_value = mock_jm
191
+
192
+ response = client.patch(
193
+ "/api/admin/jobs/test-job-123",
194
+ json={"created_at": "2025-01-01T00:00:00Z"},
195
+ )
196
+
197
+ assert response.status_code == 400
198
+ assert "not editable" in response.json()["detail"].lower()
199
+
200
+ def test_rejects_status_update(self, client, mock_job):
201
+ """Test that status cannot be updated via PATCH (use reset endpoint)."""
202
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
203
+ mock_jm = Mock()
204
+ mock_jm.get_job.return_value = mock_job
205
+ mock_jm_class.return_value = mock_jm
206
+
207
+ response = client.patch(
208
+ "/api/admin/jobs/test-job-123",
209
+ json={"status": "complete"},
210
+ )
211
+
212
+ assert response.status_code == 400
213
+ assert "not editable" in response.json()["detail"].lower()
214
+
215
+ def test_rejects_state_data_update(self, client, mock_job):
216
+ """Test that state_data cannot be updated directly."""
217
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
218
+ mock_jm = Mock()
219
+ mock_jm.get_job.return_value = mock_job
220
+ mock_jm_class.return_value = mock_jm
221
+
222
+ response = client.patch(
223
+ "/api/admin/jobs/test-job-123",
224
+ json={"state_data": {"foo": "bar"}},
225
+ )
226
+
227
+ assert response.status_code == 400
228
+ assert "not editable" in response.json()["detail"].lower()
229
+
230
+ def test_returns_404_when_job_not_found(self, client):
231
+ """Test 404 when job doesn't exist."""
232
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
233
+ mock_jm = Mock()
234
+ mock_jm.get_job.return_value = None
235
+ mock_jm_class.return_value = mock_jm
236
+
237
+ response = client.patch(
238
+ "/api/admin/jobs/nonexistent-job",
239
+ json={"artist": "New Artist"},
240
+ )
241
+
242
+ assert response.status_code == 404
243
+ assert "not found" in response.json()["detail"].lower()
244
+
245
+ def test_returns_400_for_empty_update(self, client, mock_job):
246
+ """Test 400 when no valid fields are provided."""
247
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
248
+ mock_jm = Mock()
249
+ mock_jm.get_job.return_value = mock_job
250
+ mock_jm_class.return_value = mock_jm
251
+
252
+ response = client.patch(
253
+ "/api/admin/jobs/test-job-123",
254
+ json={},
255
+ )
256
+
257
+ assert response.status_code == 400
258
+ assert "no valid" in response.json()["detail"].lower()
259
+
260
+ def test_logs_admin_changes(self, client, mock_job):
261
+ """Test that admin changes are logged."""
262
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
263
+ patch('backend.api.routes.admin.logger') as mock_logger:
264
+ mock_jm = Mock()
265
+ mock_jm.get_job.return_value = mock_job
266
+ mock_jm.update_job.return_value = True
267
+ mock_jm_class.return_value = mock_jm
268
+
269
+ response = client.patch(
270
+ "/api/admin/jobs/test-job-123",
271
+ json={"artist": "New Artist"},
272
+ )
273
+
274
+ assert response.status_code == 200
275
+ # Verify logging was called
276
+ mock_logger.info.assert_called()
277
+ log_message = mock_logger.info.call_args[0][0]
278
+ assert "admin@example.com" in log_message.lower() or "admin" in log_message.lower()
279
+
280
+ def test_update_customer_fields(self, client, mock_job):
281
+ """Test updating made-for-you customer fields."""
282
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
283
+ mock_jm = Mock()
284
+ mock_jm.get_job.return_value = mock_job
285
+ mock_jm.update_job.return_value = True
286
+ mock_jm_class.return_value = mock_jm
287
+
288
+ response = client.patch(
289
+ "/api/admin/jobs/test-job-123",
290
+ json={
291
+ "customer_email": "customer@example.com",
292
+ "customer_notes": "Special request: add extra countdown",
293
+ },
294
+ )
295
+
296
+ assert response.status_code == 200
297
+ call_args = mock_jm.update_job.call_args
298
+ assert call_args[0][1]["customer_email"] == "customer@example.com"
299
+ assert call_args[0][1]["customer_notes"] == "Special request: add extra countdown"
300
+
301
+
302
+ class TestUpdateJobAuthorization:
303
+ """Tests for authorization on the update endpoint."""
304
+
305
+ def test_requires_admin_access(self, client, mock_job):
306
+ """Test that non-admin users cannot access the endpoint."""
307
+ original_override = app.dependency_overrides.get(require_admin)
308
+
309
+ def get_non_admin():
310
+ from fastapi import HTTPException
311
+ raise HTTPException(status_code=403, detail="Admin access required")
312
+
313
+ app.dependency_overrides[require_admin] = get_non_admin
314
+
315
+ try:
316
+ response = client.patch(
317
+ "/api/admin/jobs/test-job-123",
318
+ json={"artist": "New Artist"},
319
+ headers={"Authorization": "Bearer user-token"}
320
+ )
321
+ assert response.status_code == 403
322
+ finally:
323
+ if original_override:
324
+ app.dependency_overrides[require_admin] = original_override
325
+ else:
326
+ app.dependency_overrides[require_admin] = get_mock_admin
@@ -490,3 +490,236 @@ class TestCCFunctionality:
490
490
 
491
491
  # Verify add_cc was called
492
492
  mock_mail.add_cc.assert_called()
493
+
494
+
495
+ class TestMadeForYouOrderConfirmation:
496
+ """Tests for send_made_for_you_order_confirmation method."""
497
+
498
+ def test_send_order_confirmation_success(self):
499
+ """Test successful order confirmation email."""
500
+ service = EmailService()
501
+ service.provider = Mock()
502
+ service.provider.send_email.return_value = True
503
+
504
+ result = service.send_made_for_you_order_confirmation(
505
+ to_email="customer@example.com",
506
+ artist="Test Artist",
507
+ title="Test Song",
508
+ job_id="test-job-123",
509
+ )
510
+
511
+ assert result is True
512
+ service.provider.send_email.assert_called_once()
513
+ # Verify BCC is set
514
+ call_kwargs = service.provider.send_email.call_args.kwargs
515
+ assert call_kwargs.get('bcc_emails') == ["done@nomadkaraoke.com"]
516
+
517
+ def test_send_order_confirmation_subject_format(self):
518
+ """Test order confirmation has correct subject format."""
519
+ service = EmailService()
520
+ service.provider = Mock()
521
+ service.provider.send_email.return_value = True
522
+
523
+ service.send_made_for_you_order_confirmation(
524
+ to_email="customer@example.com",
525
+ artist="Seether",
526
+ title="Tonight",
527
+ job_id="test-job-456",
528
+ )
529
+
530
+ call_kwargs = service.provider.send_email.call_args.kwargs
531
+ subject = call_kwargs.get('subject')
532
+ assert "Order Confirmed" in subject
533
+ assert "Seether" in subject
534
+ assert "Tonight" in subject
535
+
536
+ def test_send_order_confirmation_with_notes(self):
537
+ """Test order confirmation includes notes."""
538
+ service = EmailService()
539
+ service.provider = Mock()
540
+ service.provider.send_email.return_value = True
541
+
542
+ service.send_made_for_you_order_confirmation(
543
+ to_email="customer@example.com",
544
+ artist="Test Artist",
545
+ title="Test Song",
546
+ job_id="test-job-789",
547
+ notes="Wedding anniversary!",
548
+ )
549
+
550
+ call_kwargs = service.provider.send_email.call_args.kwargs
551
+ html_content = call_kwargs.get('html_content')
552
+ assert "Wedding anniversary!" in html_content
553
+
554
+ def test_send_order_confirmation_includes_delivery_promise(self):
555
+ """Test order confirmation mentions delivery timeframe."""
556
+ service = EmailService()
557
+ service.provider = Mock()
558
+ service.provider.send_email.return_value = True
559
+
560
+ service.send_made_for_you_order_confirmation(
561
+ to_email="customer@example.com",
562
+ artist="Test Artist",
563
+ title="Test Song",
564
+ job_id="test-job-abc",
565
+ )
566
+
567
+ call_kwargs = service.provider.send_email.call_args.kwargs
568
+ html_content = call_kwargs.get('html_content', '')
569
+ text_content = call_kwargs.get('text_content', '')
570
+ # Should mention delivery timeline
571
+ assert "24" in html_content or "24" in text_content
572
+
573
+ def test_send_order_confirmation_failure(self):
574
+ """Test handling of email send failure."""
575
+ service = EmailService()
576
+ service.provider = Mock()
577
+ service.provider.send_email.return_value = False
578
+
579
+ result = service.send_made_for_you_order_confirmation(
580
+ to_email="customer@example.com",
581
+ artist="Test Artist",
582
+ title="Test Song",
583
+ job_id="test-job-def",
584
+ )
585
+
586
+ assert result is False
587
+
588
+
589
+ class TestMadeForYouAdminNotification:
590
+ """Tests for send_made_for_you_admin_notification method."""
591
+
592
+ def test_send_admin_notification_success(self):
593
+ """Test successful admin notification email."""
594
+ service = EmailService()
595
+ service.provider = Mock()
596
+ service.provider.send_email.return_value = True
597
+
598
+ result = service.send_made_for_you_admin_notification(
599
+ to_email="admin@nomadkaraoke.com",
600
+ customer_email="customer@example.com",
601
+ artist="Test Artist",
602
+ title="Test Song",
603
+ job_id="job-123",
604
+ admin_login_token="test-token-abc",
605
+ )
606
+
607
+ assert result is True
608
+ service.provider.send_email.assert_called_once()
609
+
610
+ def test_send_admin_notification_subject_format(self):
611
+ """Test admin notification has correct subject format."""
612
+ service = EmailService()
613
+ service.provider = Mock()
614
+ service.provider.send_email.return_value = True
615
+
616
+ service.send_made_for_you_admin_notification(
617
+ to_email="admin@nomadkaraoke.com",
618
+ customer_email="customer@example.com",
619
+ artist="Seether",
620
+ title="Tonight",
621
+ job_id="job-123",
622
+ admin_login_token="test-token-abc",
623
+ )
624
+
625
+ call_kwargs = service.provider.send_email.call_args.kwargs
626
+ subject = call_kwargs.get('subject')
627
+ # Subject format: "Karaoke Order: {artist} - {title} [ID: {job_id}]"
628
+ assert "Karaoke Order" in subject
629
+ assert "Seether" in subject
630
+ assert "Tonight" in subject
631
+
632
+ def test_send_admin_notification_includes_customer_email(self):
633
+ """Test admin notification includes customer email."""
634
+ service = EmailService()
635
+ service.provider = Mock()
636
+ service.provider.send_email.return_value = True
637
+
638
+ service.send_made_for_you_admin_notification(
639
+ to_email="admin@nomadkaraoke.com",
640
+ customer_email="vip@example.com",
641
+ artist="Test Artist",
642
+ title="Test Song",
643
+ job_id="job-123",
644
+ admin_login_token="test-token-abc",
645
+ )
646
+
647
+ call_kwargs = service.provider.send_email.call_args.kwargs
648
+ html_content = call_kwargs.get('html_content')
649
+ assert "vip@example.com" in html_content
650
+
651
+ def test_send_admin_notification_includes_job_link(self):
652
+ """Test admin notification includes job link."""
653
+ service = EmailService()
654
+ service.provider = Mock()
655
+ service.provider.send_email.return_value = True
656
+
657
+ service.send_made_for_you_admin_notification(
658
+ to_email="admin@nomadkaraoke.com",
659
+ customer_email="customer@example.com",
660
+ artist="Test Artist",
661
+ title="Test Song",
662
+ job_id="abc-def-123",
663
+ admin_login_token="test-token-abc",
664
+ )
665
+
666
+ call_kwargs = service.provider.send_email.call_args.kwargs
667
+ html_content = call_kwargs.get('html_content')
668
+ assert "abc-def-123" in html_content
669
+
670
+ def test_send_admin_notification_includes_audio_count(self):
671
+ """Test admin notification includes audio source count."""
672
+ service = EmailService()
673
+ service.provider = Mock()
674
+ service.provider.send_email.return_value = True
675
+
676
+ service.send_made_for_you_admin_notification(
677
+ to_email="admin@nomadkaraoke.com",
678
+ customer_email="customer@example.com",
679
+ artist="Test Artist",
680
+ title="Test Song",
681
+ job_id="job-123",
682
+ admin_login_token="test-token-abc",
683
+ audio_source_count=5,
684
+ )
685
+
686
+ call_kwargs = service.provider.send_email.call_args.kwargs
687
+ html_content = call_kwargs.get('html_content')
688
+ assert "5" in html_content
689
+
690
+ def test_send_admin_notification_includes_notes(self):
691
+ """Test admin notification includes customer notes."""
692
+ service = EmailService()
693
+ service.provider = Mock()
694
+ service.provider.send_email.return_value = True
695
+
696
+ service.send_made_for_you_admin_notification(
697
+ to_email="admin@nomadkaraoke.com",
698
+ customer_email="customer@example.com",
699
+ artist="Test Artist",
700
+ title="Test Song",
701
+ job_id="job-123",
702
+ admin_login_token="test-token-abc",
703
+ notes="Rush order please!",
704
+ )
705
+
706
+ call_kwargs = service.provider.send_email.call_args.kwargs
707
+ html_content = call_kwargs.get('html_content')
708
+ assert "Rush order please!" in html_content
709
+
710
+ def test_send_admin_notification_failure(self):
711
+ """Test handling of email send failure."""
712
+ service = EmailService()
713
+ service.provider = Mock()
714
+ service.provider.send_email.return_value = False
715
+
716
+ result = service.send_made_for_you_admin_notification(
717
+ to_email="admin@nomadkaraoke.com",
718
+ customer_email="customer@example.com",
719
+ artist="Test Artist",
720
+ title="Test Song",
721
+ job_id="job-123",
722
+ admin_login_token="test-token-abc",
723
+ )
724
+
725
+ assert result is False