""" Coverage tests for processor_v2.py – error recovery paths. Targets ~24 missing statements in: - notify_research_queued: exception path, no-password path, queue fallback exception - _start_research_directly: active-record creation failure, thread-ID update failure, start_research_process exception + active-record cleanup failure - notify_research_completed / notify_research_failed: outer exception paths - _process_queue_loop: cleanup_dead_threads path, finally-block import error - _process_user_queue: engine=None path, outer exception path - _start_queued_researches: processing-flag reset on error, task-status update on error - _start_research: research-not-found ValueError - process_pending_operations_for_user: rollback failure path, error_update with report_path """ from unittest.mock import MagicMock, patch import pytest MODULE = "local_deep_research.web.queue.processor_v2" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_processor(): """Return a fresh QueueProcessorV2 without starting the background thread.""" with patch(f"{MODULE}.logger"): from local_deep_research.web.queue.processor_v2 import QueueProcessorV2 return QueueProcessorV2(check_interval=1) # --------------------------------------------------------------------------- # notify_research_queued – no password → falls through to queue fallback # --------------------------------------------------------------------------- class TestNotifyResearchQueuedFallbackPaths: def test_no_password_goes_to_queue_fallback(self): """When session_password_store returns None the code skips direct exec and calls the queue fallback path.""" proc = _make_processor() mock_session = MagicMock() mock_qs = MagicMock() with ( patch(f"{MODULE}.session_password_store") as mock_store, patch(f"{MODULE}.get_user_db_session") as mock_ctx, patch(f"{MODULE}.UserQueueService", return_value=mock_qs), ): mock_store.get_session_password.return_value = None mock_ctx.return_value.__enter__ = MagicMock( return_value=mock_session ) mock_ctx.return_value.__exit__ = MagicMock(return_value=False) proc.notify_research_queued( "alice", "r1", session_id="sess1", query="q" ) mock_qs.add_task_metadata.assert_called_once_with( task_id="r1", task_type="research", priority=0 ) def test_queue_fallback_exception_is_swallowed(self): """Exception in the queue fallback must not propagate.""" proc = _make_processor() with ( patch(f"{MODULE}.session_password_store") as mock_store, patch( f"{MODULE}.get_user_db_session", side_effect=RuntimeError("db") ), patch(f"{MODULE}.logger"), ): mock_store.get_session_password.return_value = None # Should not raise proc.notify_research_queued("alice", "r1", session_id="sess1") def test_direct_exec_exception_falls_back_to_queue(self): """Exception inside direct-exec block triggers queue fallback.""" proc = _make_processor() mock_session = MagicMock() mock_qs = MagicMock() with ( patch(f"{MODULE}.session_password_store") as mock_store, patch(f"{MODULE}.db_manager") as mock_db, patch(f"{MODULE}.get_user_db_session") as mock_ctx, patch(f"{MODULE}.UserQueueService", return_value=mock_qs), patch(f"{MODULE}.logger"), ): mock_store.get_session_password.return_value = "secret" # First call (inside direct-exec) raises; second call (fallback) succeeds mock_ctx.return_value.__enter__ = MagicMock( side_effect=[RuntimeError("boom"), mock_session] ) mock_ctx.return_value.__exit__ = MagicMock(return_value=False) mock_db.open_user_database.return_value = MagicMock() proc.notify_research_queued("alice", "r1", session_id="sess1") # Fallback queue path must have been called mock_qs.add_task_metadata.assert_called_once() # --------------------------------------------------------------------------- # _start_research_directly – active-record creation failure # --------------------------------------------------------------------------- class TestStartResearchDirectlyErrors: def test_active_record_creation_failure_returns_early(self): """If creating the active-research DB record fails the method returns without calling start_research_process.""" proc = _make_processor() with ( patch( f"{MODULE}.get_user_db_session", side_effect=RuntimeError("db") ), patch(f"{MODULE}.start_research_process") as mock_start, patch(f"{MODULE}.logger"), ): proc._start_research_directly("alice", "r1", "secret", query="q") mock_start.assert_not_called() def test_thread_id_update_failure_is_swallowed(self): """Failure to update thread_id must not crash the method.""" proc = _make_processor() mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_thread = MagicMock() mock_thread.ident = 9999 def ctx_side_effect(*args, **kwargs): cm = MagicMock() cm.__enter__ = MagicMock(return_value=mock_session) cm.__exit__ = MagicMock(return_value=False) return cm call_count = {"n": 0} def ctx_raiser(*args, **kwargs): call_count["n"] += 1 cm = MagicMock() if call_count["n"] == 1: # First call: active record creation succeeds cm.__enter__ = MagicMock(return_value=mock_session) else: # Second call: thread-id update fails cm.__enter__ = MagicMock(side_effect=RuntimeError("tid")) cm.__exit__ = MagicMock(return_value=False) return cm with ( patch(f"{MODULE}.get_user_db_session", side_effect=ctx_raiser), patch(f"{MODULE}.start_research_process", return_value=mock_thread), patch(f"{MODULE}.UserQueueService"), patch(f"{MODULE}.UserActiveResearch"), patch(f"{MODULE}.logger"), ): # Should complete without raising even though thread-id update failed proc._start_research_directly("alice", "r1", "secret", query="q") def test_duplicate_research_error_leaves_state_intact(self): """When start_research_process raises DuplicateResearchError, the active record must NOT be deleted and the ResearchHistory row must NOT be marked FAILED — that state belongs to the live thread that already owns this research_id. Mutating it would terminate a running thread from the user's perspective.""" from local_deep_research.database.models import ( ResearchHistory, UserActiveResearch, ) from local_deep_research.exceptions import DuplicateResearchError proc = _make_processor() active_record = MagicMock() research_row = MagicMock() initial_status = MagicMock() research_row.status = initial_status mock_session = MagicMock() def _query(model): q = MagicMock() q.filter_by.return_value = q q.filter.return_value = q if model is UserActiveResearch: q.first.return_value = active_record elif model is ResearchHistory: q.first.return_value = research_row else: q.first.return_value = None return q mock_session.query.side_effect = _query def ctx_side_effect(*args, **kwargs): cm = MagicMock() cm.__enter__ = MagicMock(return_value=mock_session) cm.__exit__ = MagicMock(return_value=False) return cm with ( patch(f"{MODULE}.get_user_db_session", side_effect=ctx_side_effect), patch( f"{MODULE}.start_research_process", side_effect=DuplicateResearchError( "research r1 already has a live thread" ), ), patch(f"{MODULE}.UserQueueService"), patch(f"{MODULE}.logger"), ): proc._start_research_directly("alice", "r1", "secret", query="q") # Critical invariants: no delete of active record, no status # mutation on ResearchHistory. The live thread owns that state. mock_session.delete.assert_not_called() assert research_row.status is initial_status def test_start_research_process_exception_cleans_up_active_record(self): """When start_research_process raises the active record should be deleted AND the ResearchHistory row marked FAILED.""" from local_deep_research.constants import ResearchStatus from local_deep_research.database.models import ( ResearchHistory, UserActiveResearch, ) proc = _make_processor() active_record = MagicMock() research_row = MagicMock() mock_session = MagicMock() def _query(model): q = MagicMock() q.filter_by.return_value = q q.filter.return_value = q if model is UserActiveResearch: q.first.return_value = active_record elif model is ResearchHistory: q.first.return_value = research_row else: q.first.return_value = None return q mock_session.query.side_effect = _query def ctx_side_effect(*args, **kwargs): cm = MagicMock() cm.__enter__ = MagicMock(return_value=mock_session) cm.__exit__ = MagicMock(return_value=False) return cm with ( patch(f"{MODULE}.get_user_db_session", side_effect=ctx_side_effect), patch( f"{MODULE}.start_research_process", side_effect=RuntimeError("thread error"), ), patch(f"{MODULE}.UserQueueService"), patch(f"{MODULE}.logger"), ): proc._start_research_directly("alice", "r1", "secret", query="q") # Cleanup path: delete active record AND mark research FAILED. mock_session.delete.assert_called_once_with(active_record) assert research_row.status == ResearchStatus.FAILED mock_session.commit.assert_called() # --------------------------------------------------------------------------- # notify_research_completed / notify_research_failed – outer exception # --------------------------------------------------------------------------- class TestNotifyCompletedFailedExceptions: def test_notify_completed_outer_exception_swallowed(self): """Exception in get_user_db_session must not propagate from notify_research_completed.""" proc = _make_processor() with ( patch( f"{MODULE}.get_user_db_session", side_effect=RuntimeError("boom"), ), patch(f"{MODULE}.logger"), ): proc.notify_research_completed("alice", "r1", user_password="pw") def test_notify_failed_outer_exception_swallowed(self): """Exception in get_user_db_session must not propagate from notify_research_failed.""" proc = _make_processor() with ( patch( f"{MODULE}.get_user_db_session", side_effect=RuntimeError("boom"), ), patch(f"{MODULE}.logger"), ): proc.notify_research_failed( "alice", "r1", error_message="oops", user_password="pw" ) # --------------------------------------------------------------------------- # _process_queue_loop – cleanup_dead_threads periodic sweep + import error # --------------------------------------------------------------------------- class TestProcessQueueLoopCleanup: def test_cleanup_dead_threads_called_every_6_iterations(self): """After 6 loop iterations cleanup_dead_threads should have been called once.""" proc = _make_processor() proc.running = True call_counts = {"iterations": 0} def fake_wait(t): call_counts["iterations"] += 1 if call_counts["iterations"] >= 7: proc.running = False mock_cleanup_current = MagicMock() mock_cleanup_dead = MagicMock() with ( patch.object(proc._stop_event, "wait", side_effect=fake_wait), patch(f"{MODULE}.logger"), patch.object(proc, "_users_to_check", set()), patch( f"{MODULE}.cleanup_current_thread", mock_cleanup_current, create=True, ), ): # Patch the import inside the finally block with patch.dict( "sys.modules", { "local_deep_research.database.thread_local_session": MagicMock( cleanup_current_thread=mock_cleanup_current, cleanup_dead_threads=mock_cleanup_dead, ) }, ): proc._process_queue_loop() # cleanup_dead_threads should have been invoked at least once (at iteration 6) assert mock_cleanup_dead.call_count >= 1 def test_cleanup_import_error_swallowed(self): """ImportError in the finally cleanup block must not crash the loop.""" proc = _make_processor() proc.running = True call_counts = {"n": 0} def fake_wait(t): call_counts["n"] += 1 if call_counts["n"] >= 2: proc.running = False with ( patch.object(proc._stop_event, "wait", side_effect=fake_wait), patch(f"{MODULE}.logger"), patch.object(proc, "_users_to_check", set()), patch.dict( "sys.modules", {"local_deep_research.database.thread_local_session": None}, ), ): # Should not raise proc._process_queue_loop() # --------------------------------------------------------------------------- # _process_user_queue – engine=None and outer exception # --------------------------------------------------------------------------- class TestProcessUserQueueEdgeCases: def test_engine_none_returns_false(self): """When db_manager.open_user_database returns None the method returns False.""" proc = _make_processor() with ( patch(f"{MODULE}.session_password_store") as mock_store, patch(f"{MODULE}.db_manager") as mock_db, patch(f"{MODULE}.logger"), ): mock_store.get_session_password.return_value = "pw" mock_db.open_user_database.return_value = None result = proc._process_user_queue("alice", "sess1") assert result is False def test_outer_exception_returns_false(self): """Unexpected exception in _process_user_queue must return False.""" proc = _make_processor() with ( patch(f"{MODULE}.session_password_store") as mock_store, patch(f"{MODULE}.db_manager") as mock_db, patch( f"{MODULE}.get_user_db_session", side_effect=RuntimeError("db") ), patch(f"{MODULE}.logger"), ): mock_store.get_session_password.return_value = "pw" mock_db.open_user_database.return_value = MagicMock() result = proc._process_user_queue("alice", "sess1") assert result is False # --------------------------------------------------------------------------- # _start_research – research not found raises ValueError # --------------------------------------------------------------------------- class TestStartResearchNotFound: def test_research_not_found_raises_value_error(self): """_start_research must raise ValueError when research record is missing.""" proc = _make_processor() mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.first.return_value = None queued = MagicMock() queued.research_id = "r99" queued.settings_snapshot = None with pytest.raises(ValueError, match="r99"): proc._start_research(mock_session, "alice", "pw", queued) # --------------------------------------------------------------------------- # process_pending_operations_for_user – rollback failure + error_update with report_path # --------------------------------------------------------------------------- class TestProcessPendingOperationsEdgeCases: def test_rollback_failure_after_operation_error_is_swallowed(self): """If both the operation AND its rollback raise the method must not crash.""" proc = _make_processor() proc.pending_operations["op1"] = { "username": "alice", "operation_type": "progress_update", "research_id": "r1", "progress": 50.0, } mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.first.return_value = MagicMock() mock_session.commit.side_effect = RuntimeError("commit failed") mock_session.rollback.side_effect = RuntimeError("rollback failed too") with patch(f"{MODULE}.logger"): count = proc.process_pending_operations_for_user( "alice", mock_session ) # processed_count stays 0 because commit raised before increment assert count == 0 def test_error_update_with_report_path_sets_report_path(self): """error_update operation sets report_path on the research record.""" proc = _make_processor() proc.pending_operations["op2"] = { "username": "alice", "operation_type": "error_update", "research_id": "r2", "status": "failed", "error_message": "Something went wrong", "metadata": {"key": "val"}, "completed_at": "2026-03-18T00:00:00", "report_path": "/reports/r2.html", } mock_research = MagicMock() mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research with patch(f"{MODULE}.logger"): count = proc.process_pending_operations_for_user( "alice", mock_session ) assert count == 1 assert mock_research.report_path == "/reports/r2.html" def test_no_pending_operations_returns_zero(self): """When there are no pending operations for the user return 0 immediately.""" proc = _make_processor() # Operations exist for a different user proc.pending_operations["op3"] = { "username": "bob", "operation_type": "progress_update", "research_id": "r3", "progress": 10.0, } mock_session = MagicMock() count = proc.process_pending_operations_for_user("alice", mock_session) assert count == 0 # bob's operation must still be there assert "op3" in proc.pending_operations