karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__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 (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.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
@@ -845,7 +845,8 @@ class TestAudioSearchThemeSupport:
845
845
  This is the key behavior: selecting a theme should automatically
846
846
  enable CDG and TXT output formats.
847
847
  """
848
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
848
+ from backend.api.routes.audio_search import AudioSearchRequest
849
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
849
850
 
850
851
  # When theme_id is set, enable_cdg/enable_txt should default to True
851
852
  request = AudioSearchRequest(
@@ -855,7 +856,7 @@ class TestAudioSearchThemeSupport:
855
856
  # enable_cdg and enable_txt are None (not specified)
856
857
  )
857
858
 
858
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
859
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
859
860
  request.theme_id, request.enable_cdg, request.enable_txt
860
861
  )
861
862
 
@@ -864,7 +865,8 @@ class TestAudioSearchThemeSupport:
864
865
 
865
866
  def test_audio_search_request_no_theme_no_cdg_txt(self):
866
867
  """Test that without theme_id, CDG/TXT defaults to disabled."""
867
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
868
+ from backend.api.routes.audio_search import AudioSearchRequest
869
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
868
870
 
869
871
  request = AudioSearchRequest(
870
872
  artist="Test Artist",
@@ -872,7 +874,7 @@ class TestAudioSearchThemeSupport:
872
874
  # No theme_id, no enable_cdg, no enable_txt
873
875
  )
874
876
 
875
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
877
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
876
878
  request.theme_id, request.enable_cdg, request.enable_txt
877
879
  )
878
880
 
@@ -881,7 +883,8 @@ class TestAudioSearchThemeSupport:
881
883
 
882
884
  def test_explicit_cdg_txt_overrides_theme_default(self):
883
885
  """Test that explicit enable_cdg/enable_txt values override theme defaults."""
884
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
886
+ from backend.api.routes.audio_search import AudioSearchRequest
887
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
885
888
 
886
889
  # Theme set (would default to True), but explicitly disabled
887
890
  request = AudioSearchRequest(
@@ -892,7 +895,7 @@ class TestAudioSearchThemeSupport:
892
895
  enable_txt=False,
893
896
  )
894
897
 
895
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
898
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
896
899
  request.theme_id, request.enable_cdg, request.enable_txt
897
900
  )
898
901
 
@@ -901,7 +904,8 @@ class TestAudioSearchThemeSupport:
901
904
 
902
905
  def test_explicit_cdg_txt_enables_without_theme(self):
903
906
  """Test that explicit True enables CDG/TXT even without theme."""
904
- from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
907
+ from backend.api.routes.audio_search import AudioSearchRequest
908
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
905
909
 
906
910
  # No theme (would default to False), but explicitly enabled
907
911
  request = AudioSearchRequest(
@@ -911,7 +915,7 @@ class TestAudioSearchThemeSupport:
911
915
  enable_txt=True,
912
916
  )
913
917
 
914
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
918
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
915
919
  request.theme_id, request.enable_cdg, request.enable_txt
916
920
  )
917
921
 
@@ -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