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,223 @@
1
+ """
2
+ Unit tests for user impersonation endpoint.
3
+
4
+ Tests the admin impersonation API that allows admins to view the app as other users.
5
+ """
6
+ import pytest
7
+ from unittest.mock import Mock, patch
8
+ from fastapi.testclient import TestClient
9
+ from fastapi import FastAPI
10
+
11
+ from backend.api.routes.admin import router
12
+ from backend.api.dependencies import require_admin
13
+ from backend.services.user_service import get_user_service
14
+ from backend.models.user import User, Session
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 - returns admin user."""
24
+ return ("admin@nomadkaraoke.com", "admin", 1)
25
+
26
+
27
+ def get_mock_regular_user():
28
+ """Override for require_admin dependency - returns regular user (should fail)."""
29
+ # This simulates what happens when a non-admin tries to access
30
+ # In reality, require_admin raises 403, but we test the logic
31
+ return ("user@example.com", "user", 0)
32
+
33
+
34
+ @pytest.fixture
35
+ def client():
36
+ """Create a test client with admin access."""
37
+ app.dependency_overrides[require_admin] = get_mock_admin
38
+ yield TestClient(app)
39
+ app.dependency_overrides.clear()
40
+
41
+
42
+ @pytest.fixture
43
+ def mock_user_service():
44
+ """Create a mock user service."""
45
+ service = Mock()
46
+ return service
47
+
48
+
49
+ @pytest.fixture
50
+ def mock_target_user():
51
+ """Create a mock target user to impersonate."""
52
+ user = Mock(spec=User)
53
+ user.email = "target@example.com"
54
+ user.credits = 5
55
+ user.role = "user"
56
+ user.is_active = True
57
+ return user
58
+
59
+
60
+ @pytest.fixture
61
+ def mock_session():
62
+ """Create a mock session."""
63
+ session = Mock(spec=Session)
64
+ session.token = "test-session-token-12345678901234567890"
65
+ session.user_email = "target@example.com"
66
+ return session
67
+
68
+
69
+ class TestImpersonateUser:
70
+ """Tests for POST /api/admin/users/{email}/impersonate endpoint."""
71
+
72
+ def test_admin_can_impersonate_user(self, client, mock_target_user, mock_session):
73
+ """Admin gets session token for target user."""
74
+ with patch('backend.api.routes.admin.get_user_service') as mock_get_service:
75
+ mock_service = Mock()
76
+ mock_service.get_user.return_value = mock_target_user
77
+ mock_service.create_session.return_value = mock_session
78
+ mock_get_service.return_value = mock_service
79
+
80
+ # Override the dependency
81
+ app.dependency_overrides[get_user_service] = lambda: mock_service
82
+
83
+ response = client.post(
84
+ "/api/admin/users/target@example.com/impersonate",
85
+ headers={"Authorization": "Bearer admin-token"}
86
+ )
87
+
88
+ assert response.status_code == 200
89
+ data = response.json()
90
+ assert data["session_token"] == mock_session.token
91
+ assert data["user_email"] == "target@example.com"
92
+ assert "impersonating" in data["message"].lower()
93
+
94
+ # Verify user lookup was called
95
+ mock_service.get_user.assert_called_once_with("target@example.com")
96
+
97
+ # Verify session was created for target user
98
+ mock_service.create_session.assert_called_once()
99
+ call_args = mock_service.create_session.call_args
100
+ assert call_args.kwargs["user_email"] == "target@example.com"
101
+ assert "Impersonation by admin@nomadkaraoke.com" in call_args.kwargs["user_agent"]
102
+
103
+ def test_impersonate_nonexistent_user_returns_404(self, client):
104
+ """Target user must exist."""
105
+ with patch('backend.api.routes.admin.get_user_service') as mock_get_service:
106
+ mock_service = Mock()
107
+ mock_service.get_user.return_value = None # User not found
108
+ mock_get_service.return_value = mock_service
109
+ app.dependency_overrides[get_user_service] = lambda: mock_service
110
+
111
+ response = client.post(
112
+ "/api/admin/users/nonexistent@example.com/impersonate",
113
+ headers={"Authorization": "Bearer admin-token"}
114
+ )
115
+
116
+ assert response.status_code == 404
117
+ assert "not found" in response.json()["detail"].lower()
118
+
119
+ def test_cannot_impersonate_self(self, client, mock_target_user):
120
+ """Admin cannot impersonate themselves."""
121
+ with patch('backend.api.routes.admin.get_user_service') as mock_get_service:
122
+ mock_service = Mock()
123
+ # Set up user to be found (same as admin)
124
+ mock_target_user.email = "admin@nomadkaraoke.com"
125
+ mock_service.get_user.return_value = mock_target_user
126
+ mock_get_service.return_value = mock_service
127
+ app.dependency_overrides[get_user_service] = lambda: mock_service
128
+
129
+ response = client.post(
130
+ "/api/admin/users/admin@nomadkaraoke.com/impersonate",
131
+ headers={"Authorization": "Bearer admin-token"}
132
+ )
133
+
134
+ assert response.status_code == 400
135
+ assert "yourself" in response.json()["detail"].lower()
136
+
137
+ def test_impersonation_creates_valid_session(self, client, mock_target_user, mock_session):
138
+ """Returned token is a valid session for target user."""
139
+ with patch('backend.api.routes.admin.get_user_service') as mock_get_service:
140
+ mock_service = Mock()
141
+ mock_service.get_user.return_value = mock_target_user
142
+ mock_service.create_session.return_value = mock_session
143
+ mock_get_service.return_value = mock_service
144
+ app.dependency_overrides[get_user_service] = lambda: mock_service
145
+
146
+ response = client.post(
147
+ "/api/admin/users/target@example.com/impersonate",
148
+ headers={"Authorization": "Bearer admin-token"}
149
+ )
150
+
151
+ assert response.status_code == 200
152
+
153
+ # Verify create_session was called with correct user
154
+ mock_service.create_session.assert_called_once()
155
+ call_kwargs = mock_service.create_session.call_args.kwargs
156
+ assert call_kwargs["user_email"] == "target@example.com"
157
+
158
+ def test_impersonation_logs_audit_trail(self, client, mock_target_user, mock_session):
159
+ """Impersonation is logged for security audit."""
160
+ with patch('backend.api.routes.admin.get_user_service') as mock_get_service, \
161
+ patch('backend.api.routes.admin.logger') as mock_logger:
162
+
163
+ mock_service = Mock()
164
+ mock_service.get_user.return_value = mock_target_user
165
+ mock_service.create_session.return_value = mock_session
166
+ mock_get_service.return_value = mock_service
167
+ app.dependency_overrides[get_user_service] = lambda: mock_service
168
+
169
+ response = client.post(
170
+ "/api/admin/users/target@example.com/impersonate",
171
+ headers={"Authorization": "Bearer admin-token"}
172
+ )
173
+
174
+ assert response.status_code == 200
175
+
176
+ # Verify logging was called with impersonation info
177
+ mock_logger.info.assert_called()
178
+ log_message = mock_logger.info.call_args[0][0]
179
+ assert "IMPERSONATION" in log_message
180
+ assert "admin@nomadkaraoke.com" in log_message
181
+ assert "target@example.com" in log_message
182
+
183
+ def test_email_is_normalized_to_lowercase(self, client, mock_target_user, mock_session):
184
+ """Email addresses are normalized to lowercase."""
185
+ with patch('backend.api.routes.admin.get_user_service') as mock_get_service:
186
+ mock_service = Mock()
187
+ mock_service.get_user.return_value = mock_target_user
188
+ mock_service.create_session.return_value = mock_session
189
+ mock_get_service.return_value = mock_service
190
+ app.dependency_overrides[get_user_service] = lambda: mock_service
191
+
192
+ response = client.post(
193
+ "/api/admin/users/TARGET@EXAMPLE.COM/impersonate",
194
+ headers={"Authorization": "Bearer admin-token"}
195
+ )
196
+
197
+ assert response.status_code == 200
198
+
199
+ # Verify user lookup was with lowercase
200
+ mock_service.get_user.assert_called_once_with("target@example.com")
201
+
202
+
203
+ class TestImpersonateUserNonAdmin:
204
+ """Tests to verify non-admins cannot impersonate."""
205
+
206
+ def test_non_admin_cannot_impersonate(self):
207
+ """
208
+ Regular user gets 403 Forbidden.
209
+
210
+ Note: This is enforced by the require_admin dependency at the route level.
211
+ We test that the dependency is correctly applied.
212
+ """
213
+ # Create a fresh client without admin override
214
+ test_app = FastAPI()
215
+ test_app.include_router(router, prefix="/api")
216
+
217
+ # Don't override require_admin - it should reject non-admin requests
218
+ # In a real test, we'd mock the auth service to return a non-admin user
219
+ # For now, we just verify the endpoint requires admin via dependency
220
+
221
+ # The require_admin dependency will reject requests without proper admin auth
222
+ # This is tested implicitly by the fact that all other tests use admin override
223
+ pass # Actual enforcement tested via integration tests
@@ -128,6 +128,8 @@ class TestUserEmailExtraction:
128
128
  mock_request.headers = {}
129
129
  mock_request.client = Mock(host="127.0.0.1")
130
130
  mock_request.url = Mock(path="/api/jobs/create-from-url")
131
+ # Mock tenant state (no tenant = default Nomad Karaoke)
132
+ mock_request.state.tenant_config = None
131
133
 
132
134
  mock_background_tasks = Mock()
133
135
  body = CreateJobFromUrlRequest(
@@ -168,6 +170,8 @@ class TestUserEmailExtraction:
168
170
  mock_request.headers = {}
169
171
  mock_request.client = Mock(host="127.0.0.1")
170
172
  mock_request.url = Mock(path="/api/audio-search/search")
173
+ # Mock tenant state (no tenant = default Nomad Karaoke)
174
+ mock_request.state.tenant_config = None
171
175
 
172
176
  mock_background_tasks = Mock()
173
177
  body = AudioSearchRequest(
@@ -33,13 +33,28 @@ def job_manager(mock_firestore_service):
33
33
 
34
34
  class TestJobCreation:
35
35
  """Test job creation logic."""
36
-
36
+
37
+ def test_create_job_requires_theme_id(self, job_manager, mock_firestore_service):
38
+ """Test that jobs without theme_id are rejected."""
39
+ job_create = JobCreate(
40
+ artist="Test Artist",
41
+ title="Test Song"
42
+ # No theme_id - should fail
43
+ )
44
+
45
+ with pytest.raises(ValueError, match="theme_id is required"):
46
+ job_manager.create_job(job_create)
47
+
48
+ # Verify Firestore was NOT called
49
+ mock_firestore_service.create_job.assert_not_called()
50
+
37
51
  def test_create_job_with_url(self, job_manager, mock_firestore_service):
38
52
  """Test creating a job with YouTube URL."""
39
53
  job_create = JobCreate(
40
54
  url="https://youtube.com/watch?v=test",
41
55
  artist="Test Artist",
42
- title="Test Song"
56
+ title="Test Song",
57
+ theme_id="nomad" # Required for all jobs
43
58
  )
44
59
 
45
60
  # The actual create_job method creates the job and returns it
@@ -59,7 +74,8 @@ class TestJobCreation:
59
74
  """Test creating a job without URL (for file upload)."""
60
75
  job_create = JobCreate(
61
76
  artist="Test Artist",
62
- title="Test Song"
77
+ title="Test Song",
78
+ theme_id="nomad" # Required for all jobs
63
79
  )
64
80
 
65
81
  job = job_manager.create_job(job_create)
@@ -70,8 +86,8 @@ class TestJobCreation:
70
86
 
71
87
  def test_create_job_generates_unique_id(self, job_manager, mock_firestore_service):
72
88
  """Test that each job gets a unique ID."""
73
- job_create = JobCreate()
74
-
89
+ job_create = JobCreate(theme_id="nomad") # Required for all jobs
90
+
75
91
  # Create multiple jobs
76
92
  ids = []
77
93
  for i in range(5):
@@ -89,8 +105,8 @@ class TestJobCreation:
89
105
 
90
106
  def test_create_job_sets_initial_status(self, job_manager, mock_firestore_service):
91
107
  """Test that new jobs start with PENDING status."""
92
- job_create = JobCreate()
93
-
108
+ job_create = JobCreate(theme_id="nomad") # Required for all jobs
109
+
94
110
  mock_firestore_service.create_job.return_value = Job(
95
111
  job_id="test123",
96
112
  status=JobStatus.PENDING,
@@ -105,13 +121,14 @@ class TestJobCreation:
105
121
 
106
122
  def test_create_job_with_distribution_settings(self, job_manager, mock_firestore_service):
107
123
  """Test that distribution settings are passed from JobCreate to Job.
108
-
124
+
109
125
  This was a bug where brand_prefix, dropbox_path, gdrive_folder_id, and
110
126
  discord_webhook_url were NOT being passed to the Job constructor.
111
127
  """
112
128
  job_create = JobCreate(
113
129
  artist="Test Artist",
114
130
  title="Test Song",
131
+ theme_id="nomad", # Required for all jobs
115
132
  brand_prefix="NOMAD",
116
133
  discord_webhook_url="https://discord.com/webhook/test",
117
134
  dropbox_path="/Karaoke/Tracks-Organized",
@@ -296,9 +313,154 @@ class TestJobFailure:
296
313
  assert call_args[1]['error_message'] == error_message
297
314
 
298
315
 
316
+ class TestMadeForYouFieldMapping:
317
+ """
318
+ CRITICAL: Test that made-for-you fields are properly copied from JobCreate to Job.
319
+
320
+ These tests verify that JobManager.create_job() properly maps all fields from
321
+ JobCreate to the Job object that gets persisted to Firestore.
322
+
323
+ Bug context (2026-01-09): Production made-for-you orders were created with
324
+ made_for_you=False, customer_email=None, customer_notes=None because
325
+ JobManager.create_job() wasn't copying these fields from JobCreate to Job.
326
+ """
327
+
328
+ def test_create_job_copies_made_for_you_flag(self, job_manager, mock_firestore_service):
329
+ """
330
+ CRITICAL: made_for_you flag must be copied from JobCreate to Job.
331
+
332
+ This flag is essential for:
333
+ - Ownership transfer on job completion
334
+ - Email suppression for intermediate states
335
+ - Identifying made-for-you jobs in admin UI
336
+ """
337
+ job_create = JobCreate(
338
+ artist="Test Artist",
339
+ title="Test Song",
340
+ theme_id="nomad",
341
+ made_for_you=True, # This MUST be copied to the Job
342
+ )
343
+
344
+ job = job_manager.create_job(job_create)
345
+
346
+ # CRITICAL ASSERTION: made_for_you must be True on the created Job
347
+ assert job.made_for_you is True, \
348
+ "made_for_you=True on JobCreate must be copied to Job"
349
+
350
+ # Also verify what was saved to Firestore
351
+ mock_firestore_service.create_job.assert_called_once()
352
+ saved_job = mock_firestore_service.create_job.call_args[0][0]
353
+ assert saved_job.made_for_you is True, \
354
+ "made_for_you=True must be persisted to Firestore"
355
+
356
+ def test_create_job_copies_customer_email(self, job_manager, mock_firestore_service):
357
+ """
358
+ CRITICAL: customer_email must be copied from JobCreate to Job.
359
+
360
+ This field is essential for:
361
+ - Ownership transfer on job completion (transferring to this email)
362
+ - Sending completion email with download links to customer
363
+ """
364
+ job_create = JobCreate(
365
+ artist="Test Artist",
366
+ title="Test Song",
367
+ theme_id="nomad",
368
+ made_for_you=True,
369
+ customer_email="customer@example.com", # This MUST be copied to the Job
370
+ )
371
+
372
+ job = job_manager.create_job(job_create)
373
+
374
+ # CRITICAL ASSERTION: customer_email must be copied
375
+ assert job.customer_email == "customer@example.com", \
376
+ "customer_email on JobCreate must be copied to Job"
377
+
378
+ # Also verify what was saved to Firestore
379
+ saved_job = mock_firestore_service.create_job.call_args[0][0]
380
+ assert saved_job.customer_email == "customer@example.com", \
381
+ "customer_email must be persisted to Firestore"
382
+
383
+ def test_create_job_copies_customer_notes(self, job_manager, mock_firestore_service):
384
+ """
385
+ customer_notes must be copied from JobCreate to Job.
386
+
387
+ Customer notes contain special requests that admin needs to see.
388
+ """
389
+ job_create = JobCreate(
390
+ artist="Test Artist",
391
+ title="Test Song",
392
+ theme_id="nomad",
393
+ made_for_you=True,
394
+ customer_notes="Please make this perfect for my wedding!",
395
+ )
396
+
397
+ job = job_manager.create_job(job_create)
398
+
399
+ assert job.customer_notes == "Please make this perfect for my wedding!", \
400
+ "customer_notes on JobCreate must be copied to Job"
401
+
402
+ saved_job = mock_firestore_service.create_job.call_args[0][0]
403
+ assert saved_job.customer_notes == "Please make this perfect for my wedding!", \
404
+ "customer_notes must be persisted to Firestore"
405
+
406
+ def test_create_job_full_made_for_you_config(self, job_manager, mock_firestore_service):
407
+ """
408
+ Test complete made-for-you job creation with all fields.
409
+
410
+ This is the realistic scenario: a made-for-you order creates a job with:
411
+ - made_for_you=True
412
+ - user_email=admin (owner during processing)
413
+ - customer_email=customer (for final delivery)
414
+ - customer_notes=notes (customer's special requests)
415
+ """
416
+ job_create = JobCreate(
417
+ artist="Avril Lavigne",
418
+ title="Complicated",
419
+ theme_id="nomad",
420
+ made_for_you=True,
421
+ user_email="admin@nomadkaraoke.com",
422
+ customer_email="customer@example.com",
423
+ customer_notes="Anniversary gift!",
424
+ # Distribution settings that should also be applied
425
+ enable_youtube_upload=True,
426
+ dropbox_path="/Production/Ready",
427
+ brand_prefix="NOMAD",
428
+ )
429
+
430
+ job = job_manager.create_job(job_create)
431
+
432
+ # Verify all made-for-you fields
433
+ assert job.made_for_you is True
434
+ assert job.user_email == "admin@nomadkaraoke.com"
435
+ assert job.customer_email == "customer@example.com"
436
+ assert job.customer_notes == "Anniversary gift!"
437
+
438
+ # Verify distribution settings too
439
+ assert job.enable_youtube_upload is True
440
+ assert job.dropbox_path == "/Production/Ready"
441
+ assert job.brand_prefix == "NOMAD"
442
+
443
+ def test_create_job_made_for_you_false_by_default(self, job_manager, mock_firestore_service):
444
+ """
445
+ Regular jobs should have made_for_you=False by default.
446
+ """
447
+ job_create = JobCreate(
448
+ artist="Test Artist",
449
+ title="Test Song",
450
+ theme_id="nomad",
451
+ # No made_for_you specified - should default to False
452
+ )
453
+
454
+ job = job_manager.create_job(job_create)
455
+
456
+ assert job.made_for_you is False
457
+ assert job.customer_email is None
458
+ assert job.customer_notes is None
459
+
460
+
299
461
  class TestJobDeletion:
300
462
  """Test job deletion logic."""
301
-
463
+
302
464
  def test_delete_job(self, job_manager, mock_firestore_service):
303
465
  """Test deleting a job."""
304
466
  existing_job = Job(
@@ -50,7 +50,16 @@ def mock_worker_service():
50
50
 
51
51
 
52
52
  @pytest.fixture
53
- def client(mock_job_manager, mock_worker_service):
53
+ def mock_theme_service():
54
+ """Create a mock ThemeService that returns 'nomad' as default theme."""
55
+ service = MagicMock()
56
+ service.get_default_theme_id.return_value = "nomad"
57
+ service.get_theme.return_value = None
58
+ return service
59
+
60
+
61
+ @pytest.fixture
62
+ def client(mock_job_manager, mock_worker_service, mock_theme_service):
54
63
  """Create TestClient with mocked dependencies."""
55
64
  mock_creds = MagicMock()
56
65
  mock_creds.universe_domain = 'googleapis.com'
@@ -63,6 +72,7 @@ def client(mock_job_manager, mock_worker_service):
63
72
  # Also patch JobManager class used in dependencies.py for auth checks
64
73
  with patch('backend.api.routes.jobs.job_manager', mock_job_manager), \
65
74
  patch('backend.api.routes.jobs.worker_service', mock_worker_service), \
75
+ patch('backend.api.routes.jobs.get_theme_service', return_value=mock_theme_service), \
66
76
  patch('backend.services.job_manager.JobManager', mock_job_manager_factory), \
67
77
  patch('backend.services.firestore_service.firestore'), \
68
78
  patch('backend.services.storage_service.storage'), \