karaoke-gen 0.86.7__py3-none-any.whl → 0.96.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.
- 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 +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -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 +405 -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 +842 -0
- backend/services/job_notification_service.py +271 -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/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -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 +88 -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 +339 -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 +273 -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_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/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 +525 -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/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- 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.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for worker log subcollection functionality.
|
|
3
|
+
|
|
4
|
+
Tests the WorkerLogEntry model, FirestoreService subcollection methods,
|
|
5
|
+
and JobManager log operations with the feature flag.
|
|
6
|
+
"""
|
|
7
|
+
import pytest
|
|
8
|
+
from unittest.mock import Mock, MagicMock, patch
|
|
9
|
+
from datetime import datetime, timezone, timedelta
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
# Mock Firestore before imports
|
|
13
|
+
import sys
|
|
14
|
+
sys.modules['google.cloud.firestore'] = MagicMock()
|
|
15
|
+
sys.modules['google.cloud.firestore_v1'] = MagicMock()
|
|
16
|
+
|
|
17
|
+
from backend.models.worker_log import WorkerLogEntry, DEFAULT_LOG_TTL_DAYS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestWorkerLogEntry:
|
|
21
|
+
"""Tests for WorkerLogEntry dataclass."""
|
|
22
|
+
|
|
23
|
+
def test_create_log_entry(self):
|
|
24
|
+
"""Test creating a log entry with factory method."""
|
|
25
|
+
entry = WorkerLogEntry.create(
|
|
26
|
+
job_id="job123",
|
|
27
|
+
worker="audio",
|
|
28
|
+
level="INFO",
|
|
29
|
+
message="Test message"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
assert entry.job_id == "job123"
|
|
33
|
+
assert entry.worker == "audio"
|
|
34
|
+
assert entry.level == "INFO"
|
|
35
|
+
assert entry.message == "Test message"
|
|
36
|
+
assert entry.id is not None
|
|
37
|
+
assert entry.timestamp is not None
|
|
38
|
+
assert entry.ttl_expiry is not None
|
|
39
|
+
# TTL should be ~30 days from now
|
|
40
|
+
expected_ttl = datetime.now(timezone.utc) + timedelta(days=DEFAULT_LOG_TTL_DAYS)
|
|
41
|
+
assert abs((entry.ttl_expiry - expected_ttl).total_seconds()) < 5
|
|
42
|
+
|
|
43
|
+
def test_create_log_entry_truncates_long_message(self):
|
|
44
|
+
"""Test that messages longer than 1000 chars are truncated."""
|
|
45
|
+
long_message = "x" * 2000
|
|
46
|
+
entry = WorkerLogEntry.create(
|
|
47
|
+
job_id="job123",
|
|
48
|
+
worker="audio",
|
|
49
|
+
level="INFO",
|
|
50
|
+
message=long_message
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert len(entry.message) == 1000
|
|
54
|
+
|
|
55
|
+
def test_create_log_entry_normalizes_level(self):
|
|
56
|
+
"""Test that log level is normalized to uppercase."""
|
|
57
|
+
entry = WorkerLogEntry.create(
|
|
58
|
+
job_id="job123",
|
|
59
|
+
worker="audio",
|
|
60
|
+
level="info",
|
|
61
|
+
message="Test"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert entry.level == "INFO"
|
|
65
|
+
|
|
66
|
+
def test_create_log_entry_custom_ttl(self):
|
|
67
|
+
"""Test creating entry with custom TTL."""
|
|
68
|
+
entry = WorkerLogEntry.create(
|
|
69
|
+
job_id="job123",
|
|
70
|
+
worker="audio",
|
|
71
|
+
level="INFO",
|
|
72
|
+
message="Test",
|
|
73
|
+
ttl_days=7
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expected_ttl = datetime.now(timezone.utc) + timedelta(days=7)
|
|
77
|
+
assert abs((entry.ttl_expiry - expected_ttl).total_seconds()) < 5
|
|
78
|
+
|
|
79
|
+
def test_create_log_entry_with_metadata(self):
|
|
80
|
+
"""Test creating entry with metadata."""
|
|
81
|
+
metadata = {"file_size": 1024, "duration": 300}
|
|
82
|
+
entry = WorkerLogEntry.create(
|
|
83
|
+
job_id="job123",
|
|
84
|
+
worker="audio",
|
|
85
|
+
level="INFO",
|
|
86
|
+
message="Test",
|
|
87
|
+
metadata=metadata
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert entry.metadata == metadata
|
|
91
|
+
|
|
92
|
+
def test_to_dict(self):
|
|
93
|
+
"""Test converting entry to dict for Firestore."""
|
|
94
|
+
entry = WorkerLogEntry.create(
|
|
95
|
+
job_id="job123",
|
|
96
|
+
worker="audio",
|
|
97
|
+
level="INFO",
|
|
98
|
+
message="Test message",
|
|
99
|
+
metadata={"key": "value"}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
d = entry.to_dict()
|
|
103
|
+
|
|
104
|
+
assert d["job_id"] == "job123"
|
|
105
|
+
assert d["worker"] == "audio"
|
|
106
|
+
assert d["level"] == "INFO"
|
|
107
|
+
assert d["message"] == "Test message"
|
|
108
|
+
assert d["metadata"] == {"key": "value"}
|
|
109
|
+
assert "id" in d
|
|
110
|
+
assert "timestamp" in d
|
|
111
|
+
assert "ttl_expiry" in d
|
|
112
|
+
|
|
113
|
+
def test_to_dict_without_metadata(self):
|
|
114
|
+
"""Test to_dict doesn't include metadata when None."""
|
|
115
|
+
entry = WorkerLogEntry.create(
|
|
116
|
+
job_id="job123",
|
|
117
|
+
worker="audio",
|
|
118
|
+
level="INFO",
|
|
119
|
+
message="Test"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
d = entry.to_dict()
|
|
123
|
+
assert "metadata" not in d
|
|
124
|
+
|
|
125
|
+
def test_to_legacy_dict(self):
|
|
126
|
+
"""Test converting to legacy format for API compatibility."""
|
|
127
|
+
entry = WorkerLogEntry.create(
|
|
128
|
+
job_id="job123",
|
|
129
|
+
worker="audio",
|
|
130
|
+
level="INFO",
|
|
131
|
+
message="Test message"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
d = entry.to_legacy_dict()
|
|
135
|
+
|
|
136
|
+
assert "timestamp" in d
|
|
137
|
+
assert d["level"] == "INFO"
|
|
138
|
+
assert d["worker"] == "audio"
|
|
139
|
+
assert d["message"] == "Test message"
|
|
140
|
+
# Should not include id, job_id, ttl_expiry, metadata
|
|
141
|
+
assert "id" not in d
|
|
142
|
+
assert "job_id" not in d
|
|
143
|
+
assert "ttl_expiry" not in d
|
|
144
|
+
|
|
145
|
+
def test_from_dict(self):
|
|
146
|
+
"""Test creating entry from Firestore document."""
|
|
147
|
+
timestamp = datetime.now(timezone.utc)
|
|
148
|
+
ttl_expiry = timestamp + timedelta(days=30)
|
|
149
|
+
|
|
150
|
+
data = {
|
|
151
|
+
"id": "log123",
|
|
152
|
+
"job_id": "job123",
|
|
153
|
+
"timestamp": timestamp,
|
|
154
|
+
"level": "WARNING",
|
|
155
|
+
"worker": "video",
|
|
156
|
+
"message": "Warning message",
|
|
157
|
+
"metadata": {"error_code": 500},
|
|
158
|
+
"ttl_expiry": ttl_expiry
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
entry = WorkerLogEntry.from_dict(data)
|
|
162
|
+
|
|
163
|
+
assert entry.id == "log123"
|
|
164
|
+
assert entry.job_id == "job123"
|
|
165
|
+
assert entry.level == "WARNING"
|
|
166
|
+
assert entry.worker == "video"
|
|
167
|
+
assert entry.message == "Warning message"
|
|
168
|
+
assert entry.metadata == {"error_code": 500}
|
|
169
|
+
|
|
170
|
+
def test_from_dict_with_iso_strings(self):
|
|
171
|
+
"""Test creating entry from dict with ISO format strings."""
|
|
172
|
+
data = {
|
|
173
|
+
"timestamp": "2026-01-04T12:00:00Z",
|
|
174
|
+
"level": "INFO",
|
|
175
|
+
"worker": "audio",
|
|
176
|
+
"message": "Test"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
entry = WorkerLogEntry.from_dict(data)
|
|
180
|
+
|
|
181
|
+
assert entry.timestamp.tzinfo == timezone.utc
|
|
182
|
+
assert entry.message == "Test"
|
|
183
|
+
|
|
184
|
+
def test_from_dict_missing_fields(self):
|
|
185
|
+
"""Test creating entry with missing fields uses defaults."""
|
|
186
|
+
data = {
|
|
187
|
+
"message": "Minimal log"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
entry = WorkerLogEntry.from_dict(data)
|
|
191
|
+
|
|
192
|
+
assert entry.message == "Minimal log"
|
|
193
|
+
assert entry.level == "INFO" # Default
|
|
194
|
+
assert entry.worker == "unknown" # Default
|
|
195
|
+
assert entry.id is not None # Generated
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestFirestoreServiceSubcollection:
|
|
199
|
+
"""Tests for FirestoreService log subcollection methods."""
|
|
200
|
+
|
|
201
|
+
@pytest.fixture
|
|
202
|
+
def mock_db(self):
|
|
203
|
+
"""Create mock Firestore database."""
|
|
204
|
+
return MagicMock()
|
|
205
|
+
|
|
206
|
+
@pytest.fixture
|
|
207
|
+
def firestore_service(self, mock_db):
|
|
208
|
+
"""Create FirestoreService with mocked DB."""
|
|
209
|
+
with patch('backend.services.firestore_service.firestore') as mock_firestore:
|
|
210
|
+
mock_firestore.Client.return_value = mock_db
|
|
211
|
+
from backend.services.firestore_service import FirestoreService
|
|
212
|
+
service = FirestoreService()
|
|
213
|
+
service.db = mock_db
|
|
214
|
+
return service
|
|
215
|
+
|
|
216
|
+
def test_append_log_to_subcollection(self, firestore_service, mock_db):
|
|
217
|
+
"""Test appending log to subcollection."""
|
|
218
|
+
entry = WorkerLogEntry.create(
|
|
219
|
+
job_id="job123",
|
|
220
|
+
worker="audio",
|
|
221
|
+
level="INFO",
|
|
222
|
+
message="Test message"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Setup mock chain
|
|
226
|
+
mock_collection = MagicMock()
|
|
227
|
+
mock_doc = MagicMock()
|
|
228
|
+
mock_logs_ref = MagicMock()
|
|
229
|
+
mock_log_doc = MagicMock()
|
|
230
|
+
|
|
231
|
+
mock_db.collection.return_value = mock_collection
|
|
232
|
+
mock_collection.document.return_value = mock_doc
|
|
233
|
+
mock_doc.collection.return_value = mock_logs_ref
|
|
234
|
+
mock_logs_ref.document.return_value = mock_log_doc
|
|
235
|
+
|
|
236
|
+
firestore_service.append_log_to_subcollection("job123", entry)
|
|
237
|
+
|
|
238
|
+
# Verify correct path: jobs/{job_id}/logs/{log_id}
|
|
239
|
+
mock_db.collection.assert_called_with("jobs")
|
|
240
|
+
mock_collection.document.assert_called_with("job123")
|
|
241
|
+
mock_doc.collection.assert_called_with("logs")
|
|
242
|
+
mock_logs_ref.document.assert_called_with(entry.id)
|
|
243
|
+
mock_log_doc.set.assert_called_once()
|
|
244
|
+
|
|
245
|
+
def test_get_logs_from_subcollection(self, firestore_service, mock_db):
|
|
246
|
+
"""Test getting logs from subcollection."""
|
|
247
|
+
# Setup mock chain
|
|
248
|
+
mock_collection = MagicMock()
|
|
249
|
+
mock_doc = MagicMock()
|
|
250
|
+
mock_logs_ref = MagicMock()
|
|
251
|
+
mock_query = MagicMock()
|
|
252
|
+
|
|
253
|
+
mock_db.collection.return_value = mock_collection
|
|
254
|
+
mock_collection.document.return_value = mock_doc
|
|
255
|
+
mock_doc.collection.return_value = mock_logs_ref
|
|
256
|
+
mock_logs_ref.order_by.return_value = mock_query
|
|
257
|
+
mock_query.limit.return_value = mock_query
|
|
258
|
+
|
|
259
|
+
# Mock document data
|
|
260
|
+
mock_doc1 = MagicMock()
|
|
261
|
+
mock_doc1.to_dict.return_value = {
|
|
262
|
+
"timestamp": datetime.now(timezone.utc),
|
|
263
|
+
"level": "INFO",
|
|
264
|
+
"worker": "audio",
|
|
265
|
+
"message": "Log 1"
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
mock_query.stream.return_value = iter([mock_doc1])
|
|
269
|
+
|
|
270
|
+
logs = firestore_service.get_logs_from_subcollection("job123")
|
|
271
|
+
|
|
272
|
+
assert len(logs) == 1
|
|
273
|
+
assert logs[0].message == "Log 1"
|
|
274
|
+
mock_logs_ref.order_by.assert_called_once()
|
|
275
|
+
|
|
276
|
+
def test_get_logs_from_subcollection_with_worker_filter(self, firestore_service, mock_db):
|
|
277
|
+
"""Test filtering logs by worker."""
|
|
278
|
+
mock_collection = MagicMock()
|
|
279
|
+
mock_doc = MagicMock()
|
|
280
|
+
mock_logs_ref = MagicMock()
|
|
281
|
+
mock_query = MagicMock()
|
|
282
|
+
|
|
283
|
+
mock_db.collection.return_value = mock_collection
|
|
284
|
+
mock_collection.document.return_value = mock_doc
|
|
285
|
+
mock_doc.collection.return_value = mock_logs_ref
|
|
286
|
+
mock_logs_ref.order_by.return_value = mock_query
|
|
287
|
+
mock_query.where.return_value = mock_query
|
|
288
|
+
mock_query.limit.return_value = mock_query
|
|
289
|
+
mock_query.stream.return_value = iter([])
|
|
290
|
+
|
|
291
|
+
firestore_service.get_logs_from_subcollection("job123", worker="audio")
|
|
292
|
+
|
|
293
|
+
# Verify where clause was added
|
|
294
|
+
mock_query.where.assert_called()
|
|
295
|
+
|
|
296
|
+
def test_delete_logs_subcollection(self, firestore_service, mock_db):
|
|
297
|
+
"""Test deleting all logs in subcollection."""
|
|
298
|
+
mock_collection = MagicMock()
|
|
299
|
+
mock_doc = MagicMock()
|
|
300
|
+
mock_logs_ref = MagicMock()
|
|
301
|
+
mock_batch = MagicMock()
|
|
302
|
+
|
|
303
|
+
mock_db.collection.return_value = mock_collection
|
|
304
|
+
mock_collection.document.return_value = mock_doc
|
|
305
|
+
mock_doc.collection.return_value = mock_logs_ref
|
|
306
|
+
mock_db.batch.return_value = mock_batch
|
|
307
|
+
|
|
308
|
+
# First call returns 2 docs, second call returns empty
|
|
309
|
+
mock_log_doc1 = MagicMock()
|
|
310
|
+
mock_log_doc2 = MagicMock()
|
|
311
|
+
|
|
312
|
+
call_count = [0]
|
|
313
|
+
def stream_side_effect(*args, **kwargs):
|
|
314
|
+
call_count[0] += 1
|
|
315
|
+
if call_count[0] == 1:
|
|
316
|
+
return iter([mock_log_doc1, mock_log_doc2])
|
|
317
|
+
return iter([])
|
|
318
|
+
|
|
319
|
+
mock_logs_ref.limit.return_value = mock_logs_ref
|
|
320
|
+
mock_logs_ref.stream.side_effect = stream_side_effect
|
|
321
|
+
|
|
322
|
+
deleted = firestore_service.delete_logs_subcollection("job123")
|
|
323
|
+
|
|
324
|
+
assert deleted == 2
|
|
325
|
+
mock_batch.commit.assert_called()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TestJobManagerLogging:
|
|
329
|
+
"""Tests for JobManager log methods with feature flag."""
|
|
330
|
+
|
|
331
|
+
@pytest.fixture
|
|
332
|
+
def mock_firestore_service(self):
|
|
333
|
+
"""Mock FirestoreService."""
|
|
334
|
+
with patch('backend.services.job_manager.FirestoreService') as mock:
|
|
335
|
+
service = MagicMock()
|
|
336
|
+
mock.return_value = service
|
|
337
|
+
yield service
|
|
338
|
+
|
|
339
|
+
@pytest.fixture
|
|
340
|
+
def mock_storage_service(self):
|
|
341
|
+
"""Mock StorageService."""
|
|
342
|
+
with patch('backend.services.job_manager.StorageService') as mock:
|
|
343
|
+
service = MagicMock()
|
|
344
|
+
mock.return_value = service
|
|
345
|
+
yield service
|
|
346
|
+
|
|
347
|
+
def test_append_worker_log_uses_subcollection_when_enabled(
|
|
348
|
+
self, mock_firestore_service, mock_storage_service
|
|
349
|
+
):
|
|
350
|
+
"""Test that logs go to subcollection when feature is enabled."""
|
|
351
|
+
with patch('backend.services.job_manager.settings') as mock_settings:
|
|
352
|
+
mock_settings.use_log_subcollection = True
|
|
353
|
+
|
|
354
|
+
from backend.services.job_manager import JobManager
|
|
355
|
+
manager = JobManager()
|
|
356
|
+
|
|
357
|
+
manager.append_worker_log(
|
|
358
|
+
job_id="job123",
|
|
359
|
+
worker="audio",
|
|
360
|
+
level="INFO",
|
|
361
|
+
message="Test message"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Should call subcollection method
|
|
365
|
+
mock_firestore_service.append_log_to_subcollection.assert_called_once()
|
|
366
|
+
# Should NOT call legacy method
|
|
367
|
+
mock_firestore_service.append_worker_log.assert_not_called()
|
|
368
|
+
|
|
369
|
+
def test_append_worker_log_uses_array_when_disabled(
|
|
370
|
+
self, mock_firestore_service, mock_storage_service
|
|
371
|
+
):
|
|
372
|
+
"""Test that logs go to embedded array when feature is disabled."""
|
|
373
|
+
with patch('backend.services.job_manager.settings') as mock_settings:
|
|
374
|
+
mock_settings.use_log_subcollection = False
|
|
375
|
+
|
|
376
|
+
from backend.services.job_manager import JobManager
|
|
377
|
+
manager = JobManager()
|
|
378
|
+
|
|
379
|
+
manager.append_worker_log(
|
|
380
|
+
job_id="job123",
|
|
381
|
+
worker="audio",
|
|
382
|
+
level="INFO",
|
|
383
|
+
message="Test message"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Should call legacy method
|
|
387
|
+
mock_firestore_service.append_worker_log.assert_called_once()
|
|
388
|
+
# Should NOT call subcollection method
|
|
389
|
+
mock_firestore_service.append_log_to_subcollection.assert_not_called()
|
|
390
|
+
|
|
391
|
+
def test_get_worker_logs_from_subcollection(
|
|
392
|
+
self, mock_firestore_service, mock_storage_service
|
|
393
|
+
):
|
|
394
|
+
"""Test getting logs from subcollection."""
|
|
395
|
+
with patch('backend.services.job_manager.settings') as mock_settings:
|
|
396
|
+
mock_settings.use_log_subcollection = True
|
|
397
|
+
|
|
398
|
+
entry = WorkerLogEntry.create(
|
|
399
|
+
job_id="job123",
|
|
400
|
+
worker="audio",
|
|
401
|
+
level="INFO",
|
|
402
|
+
message="Test"
|
|
403
|
+
)
|
|
404
|
+
mock_firestore_service.get_logs_from_subcollection.return_value = [entry]
|
|
405
|
+
|
|
406
|
+
from backend.services.job_manager import JobManager
|
|
407
|
+
manager = JobManager()
|
|
408
|
+
|
|
409
|
+
logs = manager.get_worker_logs("job123")
|
|
410
|
+
|
|
411
|
+
assert len(logs) == 1
|
|
412
|
+
assert logs[0]["message"] == "Test"
|
|
413
|
+
mock_firestore_service.get_logs_from_subcollection.assert_called_once()
|
|
414
|
+
|
|
415
|
+
def test_get_worker_logs_falls_back_to_array(
|
|
416
|
+
self, mock_firestore_service, mock_storage_service
|
|
417
|
+
):
|
|
418
|
+
"""Test fallback to embedded array for legacy jobs."""
|
|
419
|
+
with patch('backend.services.job_manager.settings') as mock_settings:
|
|
420
|
+
mock_settings.use_log_subcollection = True
|
|
421
|
+
|
|
422
|
+
# Subcollection returns empty
|
|
423
|
+
mock_firestore_service.get_logs_from_subcollection.return_value = []
|
|
424
|
+
|
|
425
|
+
# Mock job with legacy logs
|
|
426
|
+
from backend.models.job import Job, JobStatus
|
|
427
|
+
mock_job = Job(
|
|
428
|
+
job_id="job123",
|
|
429
|
+
status=JobStatus.PENDING,
|
|
430
|
+
created_at=datetime.now(timezone.utc),
|
|
431
|
+
updated_at=datetime.now(timezone.utc),
|
|
432
|
+
worker_logs=[
|
|
433
|
+
{"timestamp": "2026-01-04T12:00:00Z", "level": "INFO", "worker": "audio", "message": "Legacy log"}
|
|
434
|
+
]
|
|
435
|
+
)
|
|
436
|
+
mock_firestore_service.get_job.return_value = mock_job
|
|
437
|
+
|
|
438
|
+
from backend.services.job_manager import JobManager
|
|
439
|
+
manager = JobManager()
|
|
440
|
+
|
|
441
|
+
logs = manager.get_worker_logs("job123")
|
|
442
|
+
|
|
443
|
+
assert len(logs) == 1
|
|
444
|
+
assert logs[0]["message"] == "Legacy log"
|
|
445
|
+
|
|
446
|
+
def test_delete_job_deletes_logs_subcollection(
|
|
447
|
+
self, mock_firestore_service, mock_storage_service
|
|
448
|
+
):
|
|
449
|
+
"""Test that deleting a job also deletes its logs subcollection."""
|
|
450
|
+
mock_firestore_service.get_job.return_value = None
|
|
451
|
+
mock_firestore_service.delete_logs_subcollection.return_value = 5
|
|
452
|
+
|
|
453
|
+
from backend.services.job_manager import JobManager
|
|
454
|
+
manager = JobManager()
|
|
455
|
+
|
|
456
|
+
manager.delete_job("job123", delete_files=False)
|
|
457
|
+
|
|
458
|
+
mock_firestore_service.delete_logs_subcollection.assert_called_once_with("job123")
|
|
459
|
+
mock_firestore_service.delete_job.assert_called_once_with("job123")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class TestWorkerLogEntryEdgeCases:
|
|
463
|
+
"""Edge case tests for WorkerLogEntry."""
|
|
464
|
+
|
|
465
|
+
def test_create_with_empty_message(self):
|
|
466
|
+
"""Test creating entry with empty message."""
|
|
467
|
+
entry = WorkerLogEntry.create(
|
|
468
|
+
job_id="job123",
|
|
469
|
+
worker="audio",
|
|
470
|
+
level="INFO",
|
|
471
|
+
message=""
|
|
472
|
+
)
|
|
473
|
+
assert entry.message == ""
|
|
474
|
+
|
|
475
|
+
def test_create_with_unicode_message(self):
|
|
476
|
+
"""Test creating entry with Unicode characters."""
|
|
477
|
+
entry = WorkerLogEntry.create(
|
|
478
|
+
job_id="job123",
|
|
479
|
+
worker="audio",
|
|
480
|
+
level="INFO",
|
|
481
|
+
message="Processing song: 日本語の曲 - アーティスト"
|
|
482
|
+
)
|
|
483
|
+
assert "日本語" in entry.message
|
|
484
|
+
|
|
485
|
+
def test_create_with_newlines_in_message(self):
|
|
486
|
+
"""Test creating entry with newlines."""
|
|
487
|
+
entry = WorkerLogEntry.create(
|
|
488
|
+
job_id="job123",
|
|
489
|
+
worker="audio",
|
|
490
|
+
level="ERROR",
|
|
491
|
+
message="Error occurred:\nLine 1\nLine 2"
|
|
492
|
+
)
|
|
493
|
+
assert "\n" in entry.message
|
|
494
|
+
|
|
495
|
+
def test_from_dict_handles_missing_ttl_expiry(self):
|
|
496
|
+
"""Test from_dict creates default TTL when missing."""
|
|
497
|
+
data = {
|
|
498
|
+
"timestamp": datetime.now(timezone.utc),
|
|
499
|
+
"level": "INFO",
|
|
500
|
+
"worker": "audio",
|
|
501
|
+
"message": "Test"
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
entry = WorkerLogEntry.from_dict(data)
|
|
505
|
+
|
|
506
|
+
assert entry.ttl_expiry is not None
|
|
507
|
+
# Should be ~30 days from now
|
|
508
|
+
expected = datetime.now(timezone.utc) + timedelta(days=30)
|
|
509
|
+
assert abs((entry.ttl_expiry - expected).total_seconds()) < 10
|