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.
Files changed (197) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +502 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.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