karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +502 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +109 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +356 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +283 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/utils/test_data.py +27 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +535 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for Job Manager service.
|
|
3
|
+
|
|
4
|
+
Tests the job lifecycle management without requiring actual Firestore connection.
|
|
5
|
+
Uses mocking to isolate the business logic.
|
|
6
|
+
"""
|
|
7
|
+
import pytest
|
|
8
|
+
from unittest.mock import Mock, MagicMock, patch
|
|
9
|
+
from datetime import datetime, UTC
|
|
10
|
+
|
|
11
|
+
# Mock Firestore before importing JobManager
|
|
12
|
+
import sys
|
|
13
|
+
sys.modules['google.cloud.firestore'] = MagicMock()
|
|
14
|
+
|
|
15
|
+
from backend.services.job_manager import JobManager
|
|
16
|
+
from backend.models.job import Job, JobCreate, JobStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def mock_firestore_service():
|
|
21
|
+
"""Mock FirestoreService."""
|
|
22
|
+
with patch('backend.services.job_manager.FirestoreService') as mock:
|
|
23
|
+
service = Mock()
|
|
24
|
+
mock.return_value = service
|
|
25
|
+
yield service
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def job_manager(mock_firestore_service):
|
|
30
|
+
"""Create JobManager with mocked dependencies."""
|
|
31
|
+
return JobManager()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestJobCreation:
|
|
35
|
+
"""Test job creation logic."""
|
|
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
|
+
|
|
51
|
+
def test_create_job_with_url(self, job_manager, mock_firestore_service):
|
|
52
|
+
"""Test creating a job with YouTube URL."""
|
|
53
|
+
job_create = JobCreate(
|
|
54
|
+
url="https://youtube.com/watch?v=test",
|
|
55
|
+
artist="Test Artist",
|
|
56
|
+
title="Test Song",
|
|
57
|
+
theme_id="nomad" # Required for all jobs
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# The actual create_job method creates the job and returns it
|
|
61
|
+
job = job_manager.create_job(job_create)
|
|
62
|
+
|
|
63
|
+
assert job.job_id is not None
|
|
64
|
+
assert job.status == JobStatus.PENDING
|
|
65
|
+
assert job.url == "https://youtube.com/watch?v=test"
|
|
66
|
+
assert job.artist == "Test Artist"
|
|
67
|
+
assert job.title == "Test Song"
|
|
68
|
+
assert job.progress == 0
|
|
69
|
+
|
|
70
|
+
# Verify Firestore was called
|
|
71
|
+
mock_firestore_service.create_job.assert_called_once()
|
|
72
|
+
|
|
73
|
+
def test_create_job_without_url(self, job_manager, mock_firestore_service):
|
|
74
|
+
"""Test creating a job without URL (for file upload)."""
|
|
75
|
+
job_create = JobCreate(
|
|
76
|
+
artist="Test Artist",
|
|
77
|
+
title="Test Song",
|
|
78
|
+
theme_id="nomad" # Required for all jobs
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
job = job_manager.create_job(job_create)
|
|
82
|
+
|
|
83
|
+
assert job.artist == "Test Artist"
|
|
84
|
+
assert job.title == "Test Song"
|
|
85
|
+
assert job.url is None
|
|
86
|
+
|
|
87
|
+
def test_create_job_generates_unique_id(self, job_manager, mock_firestore_service):
|
|
88
|
+
"""Test that each job gets a unique ID."""
|
|
89
|
+
job_create = JobCreate(theme_id="nomad") # Required for all jobs
|
|
90
|
+
|
|
91
|
+
# Create multiple jobs
|
|
92
|
+
ids = []
|
|
93
|
+
for i in range(5):
|
|
94
|
+
mock_firestore_service.create_job.return_value = Job(
|
|
95
|
+
job_id=f"test{i}",
|
|
96
|
+
status=JobStatus.PENDING,
|
|
97
|
+
created_at=datetime.now(UTC),
|
|
98
|
+
updated_at=datetime.now(UTC)
|
|
99
|
+
)
|
|
100
|
+
job = job_manager.create_job(job_create)
|
|
101
|
+
ids.append(job.job_id)
|
|
102
|
+
|
|
103
|
+
# All IDs should be unique
|
|
104
|
+
assert len(ids) == len(set(ids))
|
|
105
|
+
|
|
106
|
+
def test_create_job_sets_initial_status(self, job_manager, mock_firestore_service):
|
|
107
|
+
"""Test that new jobs start with PENDING status."""
|
|
108
|
+
job_create = JobCreate(theme_id="nomad") # Required for all jobs
|
|
109
|
+
|
|
110
|
+
mock_firestore_service.create_job.return_value = Job(
|
|
111
|
+
job_id="test123",
|
|
112
|
+
status=JobStatus.PENDING,
|
|
113
|
+
created_at=datetime.now(UTC),
|
|
114
|
+
updated_at=datetime.now(UTC)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
job = job_manager.create_job(job_create)
|
|
118
|
+
|
|
119
|
+
assert job.status == JobStatus.PENDING
|
|
120
|
+
assert job.progress == 0
|
|
121
|
+
|
|
122
|
+
def test_create_job_with_distribution_settings(self, job_manager, mock_firestore_service):
|
|
123
|
+
"""Test that distribution settings are passed from JobCreate to Job.
|
|
124
|
+
|
|
125
|
+
This was a bug where brand_prefix, dropbox_path, gdrive_folder_id, and
|
|
126
|
+
discord_webhook_url were NOT being passed to the Job constructor.
|
|
127
|
+
"""
|
|
128
|
+
job_create = JobCreate(
|
|
129
|
+
artist="Test Artist",
|
|
130
|
+
title="Test Song",
|
|
131
|
+
theme_id="nomad", # Required for all jobs
|
|
132
|
+
brand_prefix="NOMAD",
|
|
133
|
+
discord_webhook_url="https://discord.com/webhook/test",
|
|
134
|
+
dropbox_path="/Karaoke/Tracks-Organized",
|
|
135
|
+
gdrive_folder_id="1abc123xyz",
|
|
136
|
+
enable_youtube_upload=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
job = job_manager.create_job(job_create)
|
|
140
|
+
|
|
141
|
+
# Verify distribution settings are passed through
|
|
142
|
+
assert job.brand_prefix == "NOMAD"
|
|
143
|
+
assert job.discord_webhook_url == "https://discord.com/webhook/test"
|
|
144
|
+
assert job.dropbox_path == "/Karaoke/Tracks-Organized"
|
|
145
|
+
assert job.gdrive_folder_id == "1abc123xyz"
|
|
146
|
+
assert job.enable_youtube_upload is True
|
|
147
|
+
|
|
148
|
+
# Verify Firestore was called with job containing these fields
|
|
149
|
+
mock_firestore_service.create_job.assert_called_once()
|
|
150
|
+
created_job = mock_firestore_service.create_job.call_args[0][0]
|
|
151
|
+
assert created_job.brand_prefix == "NOMAD"
|
|
152
|
+
assert created_job.dropbox_path == "/Karaoke/Tracks-Organized"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestJobRetrieval:
|
|
156
|
+
"""Test job retrieval logic."""
|
|
157
|
+
|
|
158
|
+
def test_get_existing_job(self, job_manager, mock_firestore_service):
|
|
159
|
+
"""Test retrieving an existing job."""
|
|
160
|
+
expected_job = Job(
|
|
161
|
+
job_id="test123",
|
|
162
|
+
status=JobStatus.SEPARATING_STAGE1,
|
|
163
|
+
progress=25,
|
|
164
|
+
created_at=datetime.now(UTC),
|
|
165
|
+
updated_at=datetime.now(UTC)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
mock_firestore_service.get_job.return_value = expected_job
|
|
169
|
+
|
|
170
|
+
job = job_manager.get_job("test123")
|
|
171
|
+
|
|
172
|
+
assert job.job_id == "test123"
|
|
173
|
+
assert job.status == JobStatus.SEPARATING_STAGE1
|
|
174
|
+
assert job.progress == 25
|
|
175
|
+
|
|
176
|
+
mock_firestore_service.get_job.assert_called_once_with("test123")
|
|
177
|
+
|
|
178
|
+
def test_get_nonexistent_job(self, job_manager, mock_firestore_service):
|
|
179
|
+
"""Test retrieving a nonexistent job returns None."""
|
|
180
|
+
mock_firestore_service.get_job.return_value = None
|
|
181
|
+
|
|
182
|
+
job = job_manager.get_job("nonexistent")
|
|
183
|
+
|
|
184
|
+
assert job is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestJobUpdate:
|
|
188
|
+
"""Test job update logic."""
|
|
189
|
+
|
|
190
|
+
def test_update_job_status(self, job_manager, mock_firestore_service):
|
|
191
|
+
"""Test updating job status."""
|
|
192
|
+
# Setup: job exists
|
|
193
|
+
existing_job = Job(
|
|
194
|
+
job_id="test123",
|
|
195
|
+
status=JobStatus.PENDING,
|
|
196
|
+
progress=0,
|
|
197
|
+
created_at=datetime.now(UTC),
|
|
198
|
+
updated_at=datetime.now(UTC)
|
|
199
|
+
)
|
|
200
|
+
mock_firestore_service.get_job.return_value = existing_job
|
|
201
|
+
|
|
202
|
+
# Update status
|
|
203
|
+
updates = {"status": JobStatus.SEPARATING_STAGE1, "progress": 25}
|
|
204
|
+
job_manager.update_job("test123", updates)
|
|
205
|
+
|
|
206
|
+
# Verify update was called
|
|
207
|
+
mock_firestore_service.update_job.assert_called_once()
|
|
208
|
+
call_args = mock_firestore_service.update_job.call_args
|
|
209
|
+
assert call_args[0][0] == "test123" # job_id
|
|
210
|
+
assert "status" in call_args[0][1] # updates dict
|
|
211
|
+
|
|
212
|
+
def test_update_job_multiple_fields(self, job_manager, mock_firestore_service):
|
|
213
|
+
"""Test updating multiple fields at once."""
|
|
214
|
+
existing_job = Job(
|
|
215
|
+
job_id="test123",
|
|
216
|
+
status=JobStatus.PENDING,
|
|
217
|
+
progress=0,
|
|
218
|
+
created_at=datetime.now(UTC),
|
|
219
|
+
updated_at=datetime.now(UTC),
|
|
220
|
+
timeline=[]
|
|
221
|
+
)
|
|
222
|
+
mock_firestore_service.get_job.return_value = existing_job
|
|
223
|
+
|
|
224
|
+
updates = {"status": JobStatus.SEPARATING_STAGE1, "progress": 25}
|
|
225
|
+
job_manager.update_job("test123", updates)
|
|
226
|
+
|
|
227
|
+
# Verify update was called
|
|
228
|
+
call_args = mock_firestore_service.update_job.call_args
|
|
229
|
+
updates_dict = call_args[0][1]
|
|
230
|
+
assert "status" in updates_dict
|
|
231
|
+
assert "progress" in updates_dict
|
|
232
|
+
|
|
233
|
+
def test_update_input_media_gcs_path(self, job_manager, mock_firestore_service):
|
|
234
|
+
"""Test updating input_media_gcs_path field."""
|
|
235
|
+
existing_job = Job(
|
|
236
|
+
job_id="test123",
|
|
237
|
+
status=JobStatus.PENDING,
|
|
238
|
+
progress=0,
|
|
239
|
+
created_at=datetime.now(UTC),
|
|
240
|
+
updated_at=datetime.now(UTC)
|
|
241
|
+
)
|
|
242
|
+
mock_firestore_service.get_job.return_value = existing_job
|
|
243
|
+
|
|
244
|
+
updates = {"input_media_gcs_path": "uploads/test123/file.flac"}
|
|
245
|
+
job_manager.update_job("test123", updates)
|
|
246
|
+
|
|
247
|
+
# Verify update was called with input_media_gcs_path
|
|
248
|
+
call_args = mock_firestore_service.update_job.call_args
|
|
249
|
+
updates_dict = call_args[0][1]
|
|
250
|
+
assert "input_media_gcs_path" in updates_dict
|
|
251
|
+
assert updates_dict["input_media_gcs_path"] == "uploads/test123/file.flac"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class TestJobStatusTransitions:
|
|
255
|
+
"""Test valid job status transitions."""
|
|
256
|
+
|
|
257
|
+
def test_pending_to_downloading(self, job_manager, mock_firestore_service):
|
|
258
|
+
"""Test transition from PENDING to DOWNLOADING."""
|
|
259
|
+
existing_job = Job(
|
|
260
|
+
job_id="test123",
|
|
261
|
+
status=JobStatus.PENDING,
|
|
262
|
+
progress=0,
|
|
263
|
+
created_at=datetime.now(UTC),
|
|
264
|
+
updated_at=datetime.now(UTC)
|
|
265
|
+
)
|
|
266
|
+
mock_firestore_service.get_job.return_value = existing_job
|
|
267
|
+
|
|
268
|
+
job_manager.update_job("test123", {"status": JobStatus.DOWNLOADING})
|
|
269
|
+
|
|
270
|
+
# Should succeed (valid transition)
|
|
271
|
+
mock_firestore_service.update_job.assert_called_once()
|
|
272
|
+
|
|
273
|
+
def test_downloading_to_separating(self, job_manager, mock_firestore_service):
|
|
274
|
+
"""Test transition from DOWNLOADING to SEPARATING_STAGE1."""
|
|
275
|
+
existing_job = Job(
|
|
276
|
+
job_id="test123",
|
|
277
|
+
status=JobStatus.DOWNLOADING,
|
|
278
|
+
progress=0,
|
|
279
|
+
created_at=datetime.now(UTC),
|
|
280
|
+
updated_at=datetime.now(UTC)
|
|
281
|
+
)
|
|
282
|
+
mock_firestore_service.get_job.return_value = existing_job
|
|
283
|
+
|
|
284
|
+
job_manager.update_job("test123", {"status": JobStatus.SEPARATING_STAGE1})
|
|
285
|
+
|
|
286
|
+
mock_firestore_service.update_job.assert_called_once()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestJobFailure:
|
|
290
|
+
"""Test job failure handling."""
|
|
291
|
+
|
|
292
|
+
def test_mark_job_as_failed(self, job_manager, mock_firestore_service):
|
|
293
|
+
"""Test marking a job as failed."""
|
|
294
|
+
error_message = "Audio separation failed"
|
|
295
|
+
job_manager.mark_job_failed("test123", error_message)
|
|
296
|
+
|
|
297
|
+
# mark_job_failed calls firestore.update_job_status
|
|
298
|
+
mock_firestore_service.update_job_status.assert_called_once()
|
|
299
|
+
call_args = mock_firestore_service.update_job_status.call_args
|
|
300
|
+
assert call_args[1]['job_id'] == "test123"
|
|
301
|
+
assert call_args[1]['status'] == JobStatus.FAILED
|
|
302
|
+
assert 'error_message' in call_args[1]
|
|
303
|
+
|
|
304
|
+
def test_mark_job_error(self, job_manager, mock_firestore_service):
|
|
305
|
+
"""Test marking a job with an error."""
|
|
306
|
+
error_message = "Test error"
|
|
307
|
+
job_manager.mark_job_error("test123", error_message)
|
|
308
|
+
|
|
309
|
+
# mark_job_error calls firestore.update_job_status
|
|
310
|
+
mock_firestore_service.update_job_status.assert_called_once()
|
|
311
|
+
call_args = mock_firestore_service.update_job_status.call_args
|
|
312
|
+
assert call_args[1]['job_id'] == "test123"
|
|
313
|
+
assert call_args[1]['error_message'] == error_message
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TestJobDeletion:
|
|
317
|
+
"""Test job deletion logic."""
|
|
318
|
+
|
|
319
|
+
def test_delete_job(self, job_manager, mock_firestore_service):
|
|
320
|
+
"""Test deleting a job."""
|
|
321
|
+
existing_job = Job(
|
|
322
|
+
job_id="test123",
|
|
323
|
+
status=JobStatus.COMPLETE,
|
|
324
|
+
progress=100,
|
|
325
|
+
created_at=datetime.now(UTC),
|
|
326
|
+
updated_at=datetime.now(UTC),
|
|
327
|
+
output_files={} # Empty dict so iteration works
|
|
328
|
+
)
|
|
329
|
+
mock_firestore_service.get_job.return_value = existing_job
|
|
330
|
+
|
|
331
|
+
job_manager.delete_job("test123")
|
|
332
|
+
|
|
333
|
+
# Verify delete was called
|
|
334
|
+
mock_firestore_service.delete_job.assert_called_once_with("test123")
|
|
335
|
+
|
|
336
|
+
def test_delete_job_with_files(self, job_manager, mock_firestore_service):
|
|
337
|
+
"""Test deleting a job and its files."""
|
|
338
|
+
existing_job = Job(
|
|
339
|
+
job_id="test123",
|
|
340
|
+
status=JobStatus.COMPLETE,
|
|
341
|
+
progress=100,
|
|
342
|
+
created_at=datetime.now(UTC),
|
|
343
|
+
updated_at=datetime.now(UTC),
|
|
344
|
+
output_files={} # Empty dict so iteration works
|
|
345
|
+
)
|
|
346
|
+
mock_firestore_service.get_job.return_value = existing_job
|
|
347
|
+
|
|
348
|
+
job_manager.delete_job("test123", delete_files=True)
|
|
349
|
+
|
|
350
|
+
# Verify delete was called
|
|
351
|
+
mock_firestore_service.delete_job.assert_called_once_with("test123")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
pytest.main([__file__, "-v"])
|
|
356
|
+
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for job manager notification methods.
|
|
3
|
+
|
|
4
|
+
Tests the state transition triggers for email notifications.
|
|
5
|
+
"""
|
|
6
|
+
import pytest
|
|
7
|
+
from unittest.mock import Mock, patch, MagicMock, AsyncMock
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from backend.services.job_manager import JobManager
|
|
11
|
+
from backend.models.job import Job, JobStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestTriggerStateNotifications:
|
|
15
|
+
"""Tests for _trigger_state_notifications method."""
|
|
16
|
+
|
|
17
|
+
def test_triggers_completion_email_on_complete(self):
|
|
18
|
+
"""Test that completion email is triggered when job completes."""
|
|
19
|
+
manager = JobManager()
|
|
20
|
+
manager.firestore = Mock()
|
|
21
|
+
|
|
22
|
+
mock_job = Mock(spec=Job)
|
|
23
|
+
mock_job.job_id = "job-123"
|
|
24
|
+
mock_job.user_email = "user@example.com"
|
|
25
|
+
mock_job.state_data = {"youtube_url": "https://youtube.com/123"}
|
|
26
|
+
manager.get_job = Mock(return_value=mock_job)
|
|
27
|
+
|
|
28
|
+
with patch.object(manager, '_schedule_completion_email') as mock_schedule:
|
|
29
|
+
manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
|
|
30
|
+
|
|
31
|
+
mock_schedule.assert_called_once_with(mock_job)
|
|
32
|
+
|
|
33
|
+
def test_triggers_idle_reminder_on_awaiting_review(self):
|
|
34
|
+
"""Test that idle reminder is triggered when job enters awaiting review."""
|
|
35
|
+
manager = JobManager()
|
|
36
|
+
manager.firestore = Mock()
|
|
37
|
+
|
|
38
|
+
mock_job = Mock(spec=Job)
|
|
39
|
+
mock_job.job_id = "job-123"
|
|
40
|
+
mock_job.user_email = "user@example.com"
|
|
41
|
+
mock_job.state_data = {}
|
|
42
|
+
manager.get_job = Mock(return_value=mock_job)
|
|
43
|
+
|
|
44
|
+
with patch.object(manager, '_schedule_idle_reminder') as mock_schedule:
|
|
45
|
+
manager._trigger_state_notifications("job-123", JobStatus.AWAITING_REVIEW)
|
|
46
|
+
|
|
47
|
+
mock_schedule.assert_called_once_with(mock_job, JobStatus.AWAITING_REVIEW)
|
|
48
|
+
|
|
49
|
+
def test_triggers_idle_reminder_on_awaiting_instrumental(self):
|
|
50
|
+
"""Test that idle reminder is triggered when job enters awaiting instrumental."""
|
|
51
|
+
manager = JobManager()
|
|
52
|
+
manager.firestore = Mock()
|
|
53
|
+
|
|
54
|
+
mock_job = Mock(spec=Job)
|
|
55
|
+
mock_job.job_id = "job-123"
|
|
56
|
+
mock_job.user_email = "user@example.com"
|
|
57
|
+
mock_job.state_data = {}
|
|
58
|
+
manager.get_job = Mock(return_value=mock_job)
|
|
59
|
+
|
|
60
|
+
with patch.object(manager, '_schedule_idle_reminder') as mock_schedule:
|
|
61
|
+
manager._trigger_state_notifications(
|
|
62
|
+
"job-123",
|
|
63
|
+
JobStatus.AWAITING_INSTRUMENTAL_SELECTION
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
mock_schedule.assert_called_once_with(
|
|
67
|
+
mock_job,
|
|
68
|
+
JobStatus.AWAITING_INSTRUMENTAL_SELECTION
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def test_skips_notification_when_no_job(self):
|
|
72
|
+
"""Test that notification is skipped when job not found."""
|
|
73
|
+
manager = JobManager()
|
|
74
|
+
manager.firestore = Mock()
|
|
75
|
+
manager.get_job = Mock(return_value=None)
|
|
76
|
+
|
|
77
|
+
with patch.object(manager, '_schedule_completion_email') as mock_schedule:
|
|
78
|
+
manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
|
|
79
|
+
|
|
80
|
+
mock_schedule.assert_not_called()
|
|
81
|
+
|
|
82
|
+
def test_skips_notification_when_no_user_email(self):
|
|
83
|
+
"""Test that notification is skipped when job has no user email."""
|
|
84
|
+
manager = JobManager()
|
|
85
|
+
manager.firestore = Mock()
|
|
86
|
+
|
|
87
|
+
mock_job = Mock(spec=Job)
|
|
88
|
+
mock_job.job_id = "job-123"
|
|
89
|
+
mock_job.user_email = None
|
|
90
|
+
manager.get_job = Mock(return_value=mock_job)
|
|
91
|
+
|
|
92
|
+
with patch.object(manager, '_schedule_completion_email') as mock_schedule:
|
|
93
|
+
manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
|
|
94
|
+
|
|
95
|
+
mock_schedule.assert_not_called()
|
|
96
|
+
|
|
97
|
+
def test_does_not_trigger_on_other_states(self):
|
|
98
|
+
"""Test that no notification is triggered for non-notification states."""
|
|
99
|
+
manager = JobManager()
|
|
100
|
+
manager.firestore = Mock()
|
|
101
|
+
|
|
102
|
+
mock_job = Mock(spec=Job)
|
|
103
|
+
mock_job.job_id = "job-123"
|
|
104
|
+
mock_job.user_email = "user@example.com"
|
|
105
|
+
mock_job.state_data = {}
|
|
106
|
+
manager.get_job = Mock(return_value=mock_job)
|
|
107
|
+
|
|
108
|
+
with patch.object(manager, '_schedule_completion_email') as mock_complete, \
|
|
109
|
+
patch.object(manager, '_schedule_idle_reminder') as mock_idle:
|
|
110
|
+
|
|
111
|
+
# Test various non-notification states
|
|
112
|
+
for status in [JobStatus.PENDING, JobStatus.DOWNLOADING, JobStatus.TRANSCRIBING,
|
|
113
|
+
JobStatus.RENDERING_VIDEO, JobStatus.FAILED]:
|
|
114
|
+
manager._trigger_state_notifications("job-123", status)
|
|
115
|
+
|
|
116
|
+
mock_complete.assert_not_called()
|
|
117
|
+
mock_idle.assert_not_called()
|
|
118
|
+
|
|
119
|
+
def test_handles_exception_gracefully(self):
|
|
120
|
+
"""Test that exceptions don't propagate from notifications."""
|
|
121
|
+
manager = JobManager()
|
|
122
|
+
manager.firestore = Mock()
|
|
123
|
+
manager.get_job = Mock(side_effect=Exception("Database error"))
|
|
124
|
+
|
|
125
|
+
# Should not raise
|
|
126
|
+
manager._trigger_state_notifications("job-123", JobStatus.COMPLETE)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestScheduleCompletionEmail:
|
|
130
|
+
"""Tests for _schedule_completion_email method."""
|
|
131
|
+
|
|
132
|
+
def test_extracts_urls_from_state_data(self):
|
|
133
|
+
"""Test that YouTube and Dropbox URLs are extracted from state_data."""
|
|
134
|
+
manager = JobManager()
|
|
135
|
+
manager.firestore = Mock()
|
|
136
|
+
|
|
137
|
+
mock_job = Mock(spec=Job)
|
|
138
|
+
mock_job.job_id = "job-123"
|
|
139
|
+
mock_job.user_email = "user@example.com"
|
|
140
|
+
mock_job.artist = "Test Artist"
|
|
141
|
+
mock_job.title = "Test Song"
|
|
142
|
+
mock_job.state_data = {
|
|
143
|
+
"youtube_url": "https://youtube.com/watch?v=123",
|
|
144
|
+
"dropbox_link": "https://dropbox.com/folder/abc",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
with patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_service:
|
|
148
|
+
mock_service = Mock()
|
|
149
|
+
mock_service.send_job_completion_email = AsyncMock(return_value=True)
|
|
150
|
+
mock_get_service.return_value = mock_service
|
|
151
|
+
|
|
152
|
+
# Run in sync context - this will start a daemon thread
|
|
153
|
+
manager._schedule_completion_email(mock_job)
|
|
154
|
+
|
|
155
|
+
# Give the thread a moment to execute
|
|
156
|
+
import time
|
|
157
|
+
time.sleep(0.1)
|
|
158
|
+
|
|
159
|
+
def test_handles_none_state_data(self):
|
|
160
|
+
"""Test that None state_data is handled gracefully."""
|
|
161
|
+
manager = JobManager()
|
|
162
|
+
manager.firestore = Mock()
|
|
163
|
+
|
|
164
|
+
mock_job = Mock(spec=Job)
|
|
165
|
+
mock_job.job_id = "job-123"
|
|
166
|
+
mock_job.user_email = "user@example.com"
|
|
167
|
+
mock_job.artist = None
|
|
168
|
+
mock_job.title = None
|
|
169
|
+
mock_job.state_data = None # None state_data
|
|
170
|
+
|
|
171
|
+
with patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_service:
|
|
172
|
+
mock_service = Mock()
|
|
173
|
+
mock_service.send_job_completion_email = AsyncMock(return_value=True)
|
|
174
|
+
mock_get_service.return_value = mock_service
|
|
175
|
+
|
|
176
|
+
# Should not raise
|
|
177
|
+
manager._schedule_completion_email(mock_job)
|
|
178
|
+
|
|
179
|
+
def test_handles_exception_gracefully(self):
|
|
180
|
+
"""Test that exceptions don't propagate."""
|
|
181
|
+
manager = JobManager()
|
|
182
|
+
manager.firestore = Mock()
|
|
183
|
+
|
|
184
|
+
mock_job = Mock(spec=Job)
|
|
185
|
+
mock_job.job_id = "job-123"
|
|
186
|
+
mock_job.user_email = "user@example.com"
|
|
187
|
+
mock_job.state_data = {}
|
|
188
|
+
|
|
189
|
+
with patch('backend.services.job_notification_service.get_job_notification_service') as mock_get_service:
|
|
190
|
+
mock_get_service.side_effect = Exception("Service error")
|
|
191
|
+
|
|
192
|
+
# Should not raise
|
|
193
|
+
manager._schedule_completion_email(mock_job)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestScheduleIdleReminder:
|
|
197
|
+
"""Tests for _schedule_idle_reminder method."""
|
|
198
|
+
|
|
199
|
+
def test_updates_state_data_for_lyrics_review(self):
|
|
200
|
+
"""Test that state_data is updated with blocking state info for lyrics."""
|
|
201
|
+
manager = JobManager()
|
|
202
|
+
manager.firestore = Mock()
|
|
203
|
+
|
|
204
|
+
mock_job = Mock(spec=Job)
|
|
205
|
+
mock_job.job_id = "job-123"
|
|
206
|
+
mock_job.user_email = "user@example.com"
|
|
207
|
+
mock_job.state_data = {"existing_key": "value"}
|
|
208
|
+
|
|
209
|
+
with patch('backend.services.worker_service.get_worker_service') as mock_get_service:
|
|
210
|
+
mock_service = Mock()
|
|
211
|
+
mock_service.schedule_idle_reminder = AsyncMock(return_value=True)
|
|
212
|
+
mock_get_service.return_value = mock_service
|
|
213
|
+
|
|
214
|
+
manager._schedule_idle_reminder(mock_job, JobStatus.AWAITING_REVIEW)
|
|
215
|
+
|
|
216
|
+
# Verify state_data was updated
|
|
217
|
+
update_call = manager.firestore.update_job.call_args
|
|
218
|
+
assert update_call is not None
|
|
219
|
+
updated_data = update_call[0][1] # Second positional arg
|
|
220
|
+
state_data = updated_data.get('state_data', {})
|
|
221
|
+
|
|
222
|
+
assert state_data.get('blocking_action_type') == 'lyrics'
|
|
223
|
+
assert state_data.get('reminder_sent') is False
|
|
224
|
+
assert 'blocking_state_entered_at' in state_data
|
|
225
|
+
assert state_data.get('existing_key') == 'value' # Preserved
|
|
226
|
+
|
|
227
|
+
def test_updates_state_data_for_instrumental_selection(self):
|
|
228
|
+
"""Test that state_data is updated with blocking state info for instrumental."""
|
|
229
|
+
manager = JobManager()
|
|
230
|
+
manager.firestore = Mock()
|
|
231
|
+
|
|
232
|
+
mock_job = Mock(spec=Job)
|
|
233
|
+
mock_job.job_id = "job-123"
|
|
234
|
+
mock_job.user_email = "user@example.com"
|
|
235
|
+
mock_job.state_data = {}
|
|
236
|
+
|
|
237
|
+
with patch('backend.services.worker_service.get_worker_service') as mock_get_service:
|
|
238
|
+
mock_service = Mock()
|
|
239
|
+
mock_service.schedule_idle_reminder = AsyncMock(return_value=True)
|
|
240
|
+
mock_get_service.return_value = mock_service
|
|
241
|
+
|
|
242
|
+
manager._schedule_idle_reminder(
|
|
243
|
+
mock_job,
|
|
244
|
+
JobStatus.AWAITING_INSTRUMENTAL_SELECTION
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Verify state_data was updated
|
|
248
|
+
update_call = manager.firestore.update_job.call_args
|
|
249
|
+
updated_data = update_call[0][1]
|
|
250
|
+
state_data = updated_data.get('state_data', {})
|
|
251
|
+
|
|
252
|
+
assert state_data.get('blocking_action_type') == 'instrumental'
|
|
253
|
+
|
|
254
|
+
def test_handles_none_state_data(self):
|
|
255
|
+
"""Test that None state_data is handled gracefully."""
|
|
256
|
+
manager = JobManager()
|
|
257
|
+
manager.firestore = Mock()
|
|
258
|
+
|
|
259
|
+
mock_job = Mock(spec=Job)
|
|
260
|
+
mock_job.job_id = "job-123"
|
|
261
|
+
mock_job.user_email = "user@example.com"
|
|
262
|
+
mock_job.state_data = None
|
|
263
|
+
|
|
264
|
+
with patch('backend.services.worker_service.get_worker_service') as mock_get_service:
|
|
265
|
+
mock_service = Mock()
|
|
266
|
+
mock_service.schedule_idle_reminder = AsyncMock(return_value=True)
|
|
267
|
+
mock_get_service.return_value = mock_service
|
|
268
|
+
|
|
269
|
+
# Should not raise
|
|
270
|
+
manager._schedule_idle_reminder(mock_job, JobStatus.AWAITING_REVIEW)
|
|
271
|
+
|
|
272
|
+
# Verify update was still called
|
|
273
|
+
manager.firestore.update_job.assert_called_once()
|
|
274
|
+
|
|
275
|
+
def test_handles_exception_gracefully(self):
|
|
276
|
+
"""Test that exceptions don't propagate."""
|
|
277
|
+
manager = JobManager()
|
|
278
|
+
manager.firestore = Mock()
|
|
279
|
+
|
|
280
|
+
mock_job = Mock(spec=Job)
|
|
281
|
+
mock_job.job_id = "job-123"
|
|
282
|
+
mock_job.user_email = "user@example.com"
|
|
283
|
+
mock_job.state_data = {}
|
|
284
|
+
|
|
285
|
+
# Make firestore raise an exception
|
|
286
|
+
manager.firestore.update_job.side_effect = Exception("Database error")
|
|
287
|
+
|
|
288
|
+
# Should not raise
|
|
289
|
+
manager._schedule_idle_reminder(mock_job, JobStatus.AWAITING_REVIEW)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class TestTransitionTriggersNotifications:
|
|
293
|
+
"""Integration tests for transition_to_state triggering notifications."""
|
|
294
|
+
|
|
295
|
+
def test_transition_to_complete_triggers_notification(self):
|
|
296
|
+
"""Test that transitioning to COMPLETE triggers notification."""
|
|
297
|
+
manager = JobManager()
|
|
298
|
+
manager.firestore = Mock()
|
|
299
|
+
|
|
300
|
+
mock_job = Mock(spec=Job)
|
|
301
|
+
mock_job.job_id = "job-123"
|
|
302
|
+
# Use ENCODING as the starting state since ENCODING -> COMPLETE is valid
|
|
303
|
+
mock_job.status = JobStatus.ENCODING
|
|
304
|
+
mock_job.user_email = "user@example.com"
|
|
305
|
+
mock_job.state_data = {}
|
|
306
|
+
manager.get_job = Mock(return_value=mock_job)
|
|
307
|
+
|
|
308
|
+
with patch.object(manager, '_trigger_state_notifications') as mock_trigger:
|
|
309
|
+
manager.transition_to_state("job-123", JobStatus.COMPLETE)
|
|
310
|
+
|
|
311
|
+
mock_trigger.assert_called_once_with("job-123", JobStatus.COMPLETE)
|
|
312
|
+
|
|
313
|
+
def test_transition_to_awaiting_review_triggers_notification(self):
|
|
314
|
+
"""Test that transitioning to AWAITING_REVIEW triggers notification."""
|
|
315
|
+
manager = JobManager()
|
|
316
|
+
manager.firestore = Mock()
|
|
317
|
+
|
|
318
|
+
mock_job = Mock(spec=Job)
|
|
319
|
+
mock_job.job_id = "job-123"
|
|
320
|
+
# Use APPLYING_PADDING as the starting state since APPLYING_PADDING -> AWAITING_REVIEW is valid
|
|
321
|
+
mock_job.status = JobStatus.APPLYING_PADDING
|
|
322
|
+
mock_job.user_email = "user@example.com"
|
|
323
|
+
mock_job.state_data = {}
|
|
324
|
+
manager.get_job = Mock(return_value=mock_job)
|
|
325
|
+
|
|
326
|
+
with patch.object(manager, '_trigger_state_notifications') as mock_trigger:
|
|
327
|
+
manager.transition_to_state("job-123", JobStatus.AWAITING_REVIEW)
|
|
328
|
+
|
|
329
|
+
mock_trigger.assert_called_once_with("job-123", JobStatus.AWAITING_REVIEW)
|