Files
learningcircuit--local-deep…/tests/web/queue/test_processor_v2_real.py
T
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1067 lines
40 KiB
Python

"""
Tests for QueueProcessorV2 — real integration tests for core queue orchestration.
These tests exercise the actual class methods with proper mocking of external
dependencies (database, password store, research service), unlike the fake tests
in test_processor_v2_core/operations/research.py that only test Python primitives.
Tests cover:
- notify_research_queued: direct vs queue mode branching
- notify_research_completed: status updates + notifications
- notify_research_failed: error message defaulting, sanitization
- _process_queue_loop: user:session parsing, removal logic
- _process_user_queue: slot calculation, empty queue, no password
- _start_research: new vs legacy settings format
- process_pending_operations_for_user: progress/error updates, rollback
"""
import time
import threading
from unittest.mock import Mock, MagicMock, patch
import pytest
MODULE = "local_deep_research.web.queue.processor_v2"
SETTINGS_MGR = "local_deep_research.settings.manager.SettingsManager"
def _make_processor():
"""Create a QueueProcessorV2 instance without triggering module-level side effects."""
from local_deep_research.web.queue.processor_v2 import QueueProcessorV2
return QueueProcessorV2(check_interval=1)
# ---------------------------------------------------------------------------
# notify_research_queued — direct vs queue mode branching
# ---------------------------------------------------------------------------
class TestNotifyResearchQueuedDirectMode:
"""Tests for notify_research_queued when queue_mode='direct'."""
@patch(f"{MODULE}.get_user_db_session")
@patch(f"{MODULE}.db_manager")
@patch(f"{MODULE}.session_password_store")
def test_direct_mode_slots_available_starts_directly(
self, mock_pw_store, mock_db_mgr, mock_get_session
):
"""direct mode + slots available -> calls _start_research_directly."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = "pw"
mock_db_mgr.open_user_database.return_value = Mock()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
# SettingsManager says direct mode, max 3 concurrent
mock_settings = Mock()
mock_settings.get_setting.side_effect = lambda key, default: {
"app.queue_mode": "direct",
"app.max_concurrent_researches": 3,
}.get(key, default)
# 0 active researches -> has slots
mock_session.query.return_value.filter_by.return_value.count.return_value = 0
with patch(SETTINGS_MGR, return_value=mock_settings):
with patch.object(
processor, "_start_research_directly"
) as mock_start:
processor.notify_research_queued(
"alice",
"r-001",
session_id="sess1",
query="test query",
)
mock_start.assert_called_once()
@patch(f"{MODULE}.get_user_db_session")
@patch(f"{MODULE}.db_manager")
@patch(f"{MODULE}.session_password_store")
def test_direct_mode_slots_full_falls_back_to_queue(
self, mock_pw_store, mock_db_mgr, mock_get_session
):
"""direct mode + slots full -> queues instead."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = "pw"
mock_db_mgr.open_user_database.return_value = Mock()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
mock_settings = Mock()
mock_settings.get_setting.side_effect = lambda key, default: {
"app.queue_mode": "direct",
"app.max_concurrent_researches": 2,
}.get(key, default)
# 2 active = full at max 2
mock_session.query.return_value.filter_by.return_value.count.return_value = 2
with patch(SETTINGS_MGR, return_value=mock_settings):
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
processor.notify_research_queued(
"alice",
"r-002",
session_id="sess1",
query="test",
)
mock_qs.add_task_metadata.assert_called_once()
@patch(f"{MODULE}.get_user_db_session")
@patch(f"{MODULE}.db_manager")
@patch(f"{MODULE}.session_password_store")
def test_queue_mode_always_queues(
self, mock_pw_store, mock_db_mgr, mock_get_session
):
"""queue_mode='queue' -> always queues regardless of slots."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = "pw"
mock_db_mgr.open_user_database.return_value = Mock()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
mock_settings = Mock()
mock_settings.get_setting.side_effect = lambda key, default: {
"app.queue_mode": "queue",
"app.max_concurrent_researches": 3,
}.get(key, default)
with patch(SETTINGS_MGR, return_value=mock_settings):
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
with patch.object(
processor, "_start_research_directly"
) as mock_start:
processor.notify_research_queued(
"alice",
"r-003",
session_id="sess1",
query="test",
)
mock_start.assert_not_called()
mock_qs.add_task_metadata.assert_called_once()
@patch(f"{MODULE}.get_user_db_session")
def test_missing_session_id_falls_back_to_queue(self, mock_get_session):
"""No session_id kwarg -> falls back to queue mode."""
processor = _make_processor()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
# No session_id in kwargs
processor.notify_research_queued("alice", "r-004", query="test")
mock_qs.add_task_metadata.assert_called_once()
@patch(f"{MODULE}.get_user_db_session")
@patch(f"{MODULE}.session_password_store")
def test_missing_password_falls_back_to_queue(
self, mock_pw_store, mock_get_session
):
"""No password for session -> falls back to queue mode."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = None
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
processor.notify_research_queued(
"alice", "r-005", session_id="sess1", query="test"
)
mock_qs.add_task_metadata.assert_called_once()
@patch(f"{MODULE}.get_user_db_session")
@patch(f"{MODULE}.db_manager")
@patch(f"{MODULE}.session_password_store")
def test_exception_in_direct_falls_back_to_queue(
self, mock_pw_store, mock_db_mgr, mock_get_session
):
"""Exception during direct execution -> falls back to queue."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = "pw"
mock_db_mgr.open_user_database.side_effect = RuntimeError("DB error")
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
processor.notify_research_queued(
"alice", "r-006", session_id="sess1", query="test"
)
mock_qs.add_task_metadata.assert_called_once()
@patch(f"{MODULE}.get_user_db_session")
def test_no_kwargs_queues_directly(self, mock_get_session):
"""No kwargs at all -> goes straight to queue fallback."""
processor = _make_processor()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
processor.notify_research_queued("alice", "r-007")
mock_qs.add_task_metadata.assert_called_once()
# ---------------------------------------------------------------------------
# notify_research_completed — updates status + sends notification
# ---------------------------------------------------------------------------
class TestNotifyResearchCompleted:
"""Tests for notify_research_completed."""
@patch(f"{MODULE}.send_research_completed_notification_from_session")
@patch(f"{MODULE}.get_user_db_session")
def test_updates_status_and_sends_notification(
self, mock_get_session, mock_send_notif
):
"""Updates queue status to COMPLETED and sends notification."""
processor = _make_processor()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
processor.notify_research_completed(
"alice", "r-010", user_password="pw"
)
mock_qs.update_task_status.assert_called_once()
mock_send_notif.assert_called_once_with(
username="alice",
research_id="r-010",
db_session=mock_session,
)
@patch(f"{MODULE}.get_user_db_session")
def test_exception_does_not_propagate(self, mock_get_session):
"""Exception in status update is caught silently."""
processor = _make_processor()
mock_get_session.side_effect = RuntimeError("DB unavailable")
# Should not raise
processor.notify_research_completed("alice", "r-011")
@patch(f"{MODULE}.send_research_completed_notification_from_session")
@patch(f"{MODULE}.get_user_db_session")
def test_passes_password_to_session(
self, mock_get_session, mock_send_notif
):
"""User password is passed to get_user_db_session."""
processor = _make_processor()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService"):
processor.notify_research_completed(
"bob", "r-012", user_password="secret"
)
mock_get_session.assert_called_once_with("bob", "secret")
# ---------------------------------------------------------------------------
# notify_research_failed — error message defaulting, sanitization
# ---------------------------------------------------------------------------
class TestNotifyResearchFailed:
"""Tests for notify_research_failed."""
@patch(f"{MODULE}.send_research_failed_notification_from_session")
@patch(f"{MODULE}.get_user_db_session")
def test_sends_error_message_to_queue_service(
self, mock_get_session, mock_send_notif
):
"""Passes error_message to queue service update."""
processor = _make_processor()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
mock_qs = Mock()
mock_qs_class.return_value = mock_qs
processor.notify_research_failed(
"alice", "r-020", error_message="OOM", user_password="pw"
)
mock_qs.update_task_status.assert_called_once()
args, kwargs = mock_qs.update_task_status.call_args
assert (
kwargs.get("error_message") == "OOM" or args[2]
if len(args) > 2
else True
)
@patch(f"{MODULE}.send_research_failed_notification_from_session")
@patch(f"{MODULE}.get_user_db_session")
def test_defaults_error_message_to_unknown(
self, mock_get_session, mock_send_notif
):
"""None error_message defaults to 'Unknown error' for notification."""
processor = _make_processor()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
with patch(f"{MODULE}.UserQueueService"):
processor.notify_research_failed(
"alice", "r-021", user_password="pw"
)
mock_send_notif.assert_called_once_with(
username="alice",
research_id="r-021",
error_message="Unknown error",
db_session=mock_session,
)
@patch(f"{MODULE}.get_user_db_session")
def test_exception_does_not_propagate(self, mock_get_session):
"""Exception in failure handling is caught."""
processor = _make_processor()
mock_get_session.side_effect = RuntimeError("DB error")
# Should not raise
processor.notify_research_failed("alice", "r-022", error_message="bad")
# ---------------------------------------------------------------------------
# _process_queue_loop — user:session parsing, removal logic
# ---------------------------------------------------------------------------
class TestProcessQueueLoop:
"""Tests for _process_queue_loop."""
def test_parses_user_session_format(self):
"""Correctly parses (username, session_id) tuple entries."""
processor = _make_processor()
processor._users_to_check.add(("alice", "sess-abc"))
with patch.object(
processor, "_process_user_queue", return_value=True
) as mock_pq:
# Run one iteration then stop
processor.running = True
def stop_after_one():
time.sleep(0.05)
processor.running = False
threading.Thread(target=stop_after_one).start()
processor._process_queue_loop()
mock_pq.assert_called_with("alice", "sess-abc")
def test_removes_users_with_empty_queues(self):
"""Users whose queues are empty get removed from check set."""
processor = _make_processor()
processor._users_to_check.add(("alice", "sess1"))
processor._users_to_check.add(("bob", "sess2"))
def side_effect(username, session_id):
return username == "alice" # alice's queue is empty
with patch.object(
processor, "_process_user_queue", side_effect=side_effect
):
processor.running = True
def stop_after_one():
time.sleep(0.05)
processor.running = False
threading.Thread(target=stop_after_one).start()
processor._process_queue_loop()
assert ("alice", "sess1") not in processor._users_to_check
assert ("bob", "sess2") in processor._users_to_check
def test_keeps_users_on_error(self):
"""Users are kept if _process_user_queue raises."""
processor = _make_processor()
processor._users_to_check.add(("alice", "sess1"))
with patch.object(
processor,
"_process_user_queue",
side_effect=RuntimeError("transient"),
):
processor.running = True
def stop_after_one():
time.sleep(0.05)
processor.running = False
threading.Thread(target=stop_after_one).start()
processor._process_queue_loop()
assert ("alice", "sess1") in processor._users_to_check
# ---------------------------------------------------------------------------
# _process_user_queue — slot calculation, empty queue, no password
# ---------------------------------------------------------------------------
class TestProcessUserQueue:
"""Tests for _process_user_queue."""
@patch(f"{MODULE}.session_password_store")
def test_no_password_returns_true(self, mock_pw_store):
"""No password -> returns True (remove from checking)."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = None
result = processor._process_user_queue("alice", "sess1")
assert result is True
@patch(f"{MODULE}.get_user_db_session")
@patch(f"{MODULE}.db_manager")
@patch(f"{MODULE}.session_password_store")
def test_empty_queue_returns_true(
self, mock_pw_store, mock_db_mgr, mock_get_session
):
"""Empty queue -> returns True."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = "pw"
mock_db_mgr.open_user_database.return_value = Mock()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
mock_settings = Mock()
mock_settings.get_setting.return_value = 3
mock_qs = Mock()
mock_qs.get_queue_status.return_value = {
"active_tasks": 0,
"queued_tasks": 0,
}
with patch(SETTINGS_MGR, return_value=mock_settings):
with patch(f"{MODULE}.UserQueueService", return_value=mock_qs):
result = processor._process_user_queue("alice", "sess1")
assert result is True
@patch(f"{MODULE}.get_user_db_session")
@patch(f"{MODULE}.db_manager")
@patch(f"{MODULE}.session_password_store")
def test_no_slots_available_returns_false(
self, mock_pw_store, mock_db_mgr, mock_get_session
):
"""No available slots -> returns False (keep checking)."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = "pw"
mock_db_mgr.open_user_database.return_value = Mock()
mock_session = MagicMock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=False)
mock_get_session.return_value = mock_session
mock_settings = Mock()
mock_settings.get_setting.return_value = 2
mock_qs = Mock()
mock_qs.get_queue_status.return_value = {
"active_tasks": 2,
"queued_tasks": 1,
}
with patch(SETTINGS_MGR, return_value=mock_settings):
with patch(f"{MODULE}.UserQueueService", return_value=mock_qs):
result = processor._process_user_queue("alice", "sess1")
assert result is False
@patch(f"{MODULE}.db_manager")
@patch(f"{MODULE}.session_password_store")
def test_failed_db_open_returns_false(self, mock_pw_store, mock_db_mgr):
"""Failed database open -> returns False (keep checking)."""
processor = _make_processor()
mock_pw_store.get_session_password.return_value = "pw"
mock_db_mgr.open_user_database.return_value = None
result = processor._process_user_queue("alice", "sess1")
assert result is False
# ---------------------------------------------------------------------------
# _start_research — new vs legacy settings format
# ---------------------------------------------------------------------------
class TestStartResearch:
"""Tests for _start_research."""
@patch(f"{MODULE}.start_research_process")
def test_new_format_extracts_submission_and_settings(
self, mock_start_research
):
"""New format: {submission: {...}, settings_snapshot: {...}}."""
processor = _make_processor()
mock_thread = Mock()
mock_thread.ident = 12345
mock_start_research.return_value = mock_thread
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = "queued"
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
queued = Mock()
queued.research_id = "r-100"
queued.query = "test query"
queued.mode = "standard"
queued.settings_snapshot = {
"submission": {
"model_provider": "openai",
"model": "gpt-4",
"strategy": "graph-based",
},
"settings_snapshot": {"setting_a": "value_a"},
}
with patch(f"{MODULE}.UserActiveResearch"):
processor._start_research(mock_session, "alice", "pw", queued)
# Verify start_research_process was called with new-format extracted params
call_kwargs = mock_start_research.call_args.kwargs
assert call_kwargs["model_provider"] == "openai"
assert call_kwargs["model"] == "gpt-4"
assert call_kwargs["strategy"] == "graph-based"
assert call_kwargs["settings_snapshot"] == {"setting_a": "value_a"}
@patch(f"{MODULE}.start_research_process")
def test_legacy_format_uses_flat_dict(self, mock_start_research):
"""Legacy format: flat dict used as submission_params."""
from local_deep_research.constants import ResearchStatus
processor = _make_processor()
mock_thread = Mock()
mock_thread.ident = 12345
mock_start_research.return_value = mock_thread
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = ResearchStatus.QUEUED
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
queued = Mock()
queued.research_id = "r-101"
queued.query = "legacy query"
queued.mode = "standard"
queued.settings_snapshot = {
"model_provider": "anthropic",
"model": "claude-3",
}
with patch(f"{MODULE}.UserActiveResearch"):
processor._start_research(mock_session, "alice", "pw", queued)
call_kwargs = mock_start_research.call_args.kwargs
assert call_kwargs["model_provider"] == "anthropic"
assert call_kwargs["model"] == "claude-3"
assert call_kwargs["settings_snapshot"] == {}
@patch(f"{MODULE}.start_research_process")
def test_legacy_format_seeds_search_tool_from_search_engine(
self, mock_start_research
):
"""A legacy-flat row that carries a search_engine seeds the (otherwise
empty) settings_snapshot with search.tool, so the worker's egress build
(resolve_run_primary_engine) doesn't fail closed on an empty snapshot
and refuse a replayed pre-upgrade queued run."""
from local_deep_research.constants import ResearchStatus
processor = _make_processor()
mock_thread = Mock()
mock_thread.ident = 1
mock_start_research.return_value = mock_thread
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = ResearchStatus.QUEUED
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
queued = Mock()
queued.research_id = "r-102"
queued.query = "legacy query"
queued.mode = "standard"
queued.settings_snapshot = {
"model_provider": "anthropic",
"search_engine": "pubmed",
}
with patch(f"{MODULE}.UserActiveResearch"):
processor._start_research(mock_session, "alice", "pw", queued)
call_kwargs = mock_start_research.call_args.kwargs
assert call_kwargs["settings_snapshot"] == {"search.tool": "pubmed"}
assert call_kwargs["search_engine"] == "pubmed"
@patch(f"{MODULE}.start_research_process")
def test_none_settings_snapshot_handled(self, mock_start_research):
"""None settings_snapshot treated as empty dict."""
from local_deep_research.constants import ResearchStatus
processor = _make_processor()
mock_thread = Mock()
mock_thread.ident = 12345
mock_start_research.return_value = mock_thread
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = ResearchStatus.QUEUED
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
queued = Mock()
queued.research_id = "r-102"
queued.query = "test"
queued.mode = "standard"
queued.settings_snapshot = None
with patch(f"{MODULE}.UserActiveResearch"):
processor._start_research(mock_session, "alice", "pw", queued)
call_kwargs = mock_start_research.call_args.kwargs
assert call_kwargs["settings_snapshot"] == {}
def test_research_not_found_raises(self):
"""Raises ValueError when research record not found."""
processor = _make_processor()
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
queued = Mock()
queued.research_id = "r-999"
queued.settings_snapshot = {}
with pytest.raises(ValueError, match="not found"):
processor._start_research(mock_session, "alice", "pw", queued)
@patch(f"{MODULE}.start_research_process")
def test_status_set_in_progress_before_spawn(self, mock_start_research):
"""IN_PROGRESS must be committed BEFORE start_research_process is
called, so a fast thread cannot race ahead and have its terminal
status overwritten by our post-spawn commit."""
from local_deep_research.constants import ResearchStatus
processor = _make_processor()
mock_thread = Mock()
mock_thread.ident = 999
mock_start_research.return_value = mock_thread
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = ResearchStatus.QUEUED
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
# Record the status at the moment start_research_process is called.
status_at_spawn = {}
def _capture_status(*args, **kwargs):
status_at_spawn["value"] = mock_research.status
# Capture the commit count at the moment of spawn so we can
# assert the IN_PROGRESS commit already happened, not merely
# that some commit happened at some point in the method.
status_at_spawn["commit_count"] = mock_session.commit.call_count
return mock_thread
mock_start_research.side_effect = _capture_status
queued = Mock()
queued.research_id = "r-race"
queued.query = "q"
queued.mode = "standard"
queued.settings_snapshot = {}
with patch(f"{MODULE}.UserActiveResearch"):
processor._start_research(mock_session, "alice", "pw", queued)
assert status_at_spawn["value"] == ResearchStatus.IN_PROGRESS
# Exactly one commit (the IN_PROGRESS write) must have already
# run by the time start_research_process is called.
assert status_at_spawn["commit_count"] == 1
@patch(f"{MODULE}.start_research_process")
def test_spawn_failure_resets_status_to_queued(self, mock_start_research):
"""On genuine spawn failure (non-duplicate), status must be rolled
back to QUEUED so the next retry sees a clean row and the
research is not stuck IN_PROGRESS if the retry budget is
eventually exhausted."""
from local_deep_research.constants import ResearchStatus
processor = _make_processor()
mock_start_research.side_effect = RuntimeError("spawn failed")
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = ResearchStatus.QUEUED
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
queued = Mock()
queued.research_id = "r-fail"
queued.query = "q"
queued.mode = "standard"
queued.settings_snapshot = {}
with pytest.raises(RuntimeError, match="spawn failed"):
processor._start_research(mock_session, "alice", "pw", queued)
assert mock_research.status == ResearchStatus.QUEUED
# One commit for IN_PROGRESS before spawn, a second for the QUEUED
# reset after spawn failed. Without the second commit the in-memory
# attribute is rolled back but the DB still shows IN_PROGRESS, so
# assert that both commits actually ran.
assert mock_session.commit.call_count >= 2
@patch(f"{MODULE}.start_research_process")
def test_duplicate_research_error_does_not_reset_status(
self, mock_start_research
):
"""DuplicateResearchError means a live thread already exists for
this research_id (typically a retry after a post-spawn commit
failure). Mutating status in that case would contradict the
running thread — it must be left IN_PROGRESS."""
from local_deep_research.constants import ResearchStatus
from local_deep_research.exceptions import DuplicateResearchError
processor = _make_processor()
mock_start_research.side_effect = DuplicateResearchError(
"thread already live"
)
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = ResearchStatus.QUEUED
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
queued = Mock()
queued.research_id = "r-dup"
queued.query = "q"
queued.mode = "standard"
queued.settings_snapshot = {}
with pytest.raises(DuplicateResearchError):
processor._start_research(mock_session, "alice", "pw", queued)
# Status must remain IN_PROGRESS (set before spawn, never reset).
assert mock_research.status == ResearchStatus.IN_PROGRESS
@patch(f"{MODULE}.start_research_process")
def test_non_queued_status_raises_duplicate_without_spawning(
self, mock_start_research
):
"""If _start_research is re-entered on a retry and finds the row
already in a non-QUEUED state (IN_PROGRESS from a prior attempt
whose post-spawn commit failed, or terminal COMPLETED/FAILED
because the prior thread already finished and cleaned up), it
must raise DuplicateResearchError *without* mutating status and
*without* calling start_research_process. Otherwise we would
overwrite a terminal status with IN_PROGRESS and spawn a second
thread that re-runs the whole research."""
from local_deep_research.constants import ResearchStatus
from local_deep_research.exceptions import DuplicateResearchError
for starting_status in (
ResearchStatus.IN_PROGRESS,
ResearchStatus.COMPLETED,
ResearchStatus.FAILED,
ResearchStatus.SUSPENDED,
):
processor = _make_processor()
mock_start_research.reset_mock()
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = starting_status
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
queued = Mock()
queued.research_id = f"r-stale-{starting_status}"
queued.query = "q"
queued.mode = "standard"
queued.settings_snapshot = {}
with pytest.raises(DuplicateResearchError):
processor._start_research(mock_session, "alice", "pw", queued)
# Status unchanged — we did not overwrite a terminal state.
assert mock_research.status == starting_status
# Spawn never attempted — no risk of a second thread.
mock_start_research.assert_not_called()
# No commit for a status we did not change.
mock_session.commit.assert_not_called()
@patch(f"{MODULE}.UserActiveResearch")
@patch(f"{MODULE}.start_research_process")
def test_user_active_research_commit_failure_raises_duplicate(
self, mock_start_research, mock_user_active_research
):
"""If the post-spawn UserActiveResearch commit fails, the thread
is already running. We must raise DuplicateResearchError so the
caller's dup branch deletes the queue row without bumping the
spawn-retry counter — otherwise a commit failure at the retry
limit would mark a LIVE thread as terminal FAILED."""
from local_deep_research.constants import ResearchStatus
from local_deep_research.exceptions import DuplicateResearchError
processor = _make_processor()
mock_thread = Mock()
mock_thread.ident = 42
mock_start_research.return_value = mock_thread
mock_session = MagicMock()
mock_research = Mock()
mock_research.status = ResearchStatus.QUEUED
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
# Two commits: one for IN_PROGRESS (succeeds), one for
# UserActiveResearch (fails).
commit_results = [None, RuntimeError("UAR commit failed")]
def _commit():
r = commit_results.pop(0)
if isinstance(r, Exception):
raise r
mock_session.commit.side_effect = _commit
queued = Mock()
queued.research_id = "r-uar-fail"
queued.query = "q"
queued.mode = "standard"
queued.settings_snapshot = {}
with pytest.raises(DuplicateResearchError):
processor._start_research(mock_session, "alice", "pw", queued)
# Spawn *was* called (that's how we get into the post-spawn commit).
mock_start_research.assert_called_once()
# Rollback happened after the commit failure.
assert mock_session.rollback.called
# Status must remain IN_PROGRESS — the thread is live, resetting
# would contradict its own writes. This is the key invariant
# that distinguishes this branch from the generic spawn-failure
# branch (which DOES reset to QUEUED).
assert mock_research.status == ResearchStatus.IN_PROGRESS
# Regression guard: _start_research itself must not touch the
# spawn-retry counter on the UAR-commit-failure path. The
# counter is the caller's responsibility (the dup branch in
# _start_queued_researches clears it); if anyone moves bump
# logic into _start_research, this assertion catches it and
# prevents a live thread from being marked terminal FAILED at
# SPAWN_RETRY_LIMIT.
assert "r-uar-fail" not in processor._spawn_retry_counts
# ---------------------------------------------------------------------------
# process_pending_operations_for_user — progress/error updates, rollback
# ---------------------------------------------------------------------------
class TestProcessPendingOperationsForUser:
"""Tests for process_pending_operations_for_user."""
def test_progress_update_sets_progress(self):
"""Progress update operation sets research.progress."""
processor = _make_processor()
# Queue a progress update
processor.queue_progress_update("alice", "r-200", 42.5)
mock_session = MagicMock()
mock_research = Mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
count = processor.process_pending_operations_for_user(
"alice", mock_session
)
assert count == 1
assert mock_research.progress == 42.5
mock_session.commit.assert_called()
def test_error_update_sets_all_fields(self):
"""Error update sets status, error_message, metadata, completed_at."""
processor = _make_processor()
processor.queue_error_update(
username="alice",
research_id="r-201",
status="failed",
error_message="OOM killed",
metadata={"retries": 3},
completed_at="2026-01-01T00:00:00",
report_path="/tmp/error.html",
)
mock_session = MagicMock()
mock_research = Mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
count = processor.process_pending_operations_for_user(
"alice", mock_session
)
assert count == 1
assert mock_research.status == "failed"
assert mock_research.error_message == "OOM killed"
assert mock_research.research_meta == {"retries": 3}
assert mock_research.completed_at == "2026-01-01T00:00:00"
assert mock_research.report_path == "/tmp/error.html"
def test_no_operations_returns_zero(self):
"""No pending operations for user -> returns 0."""
processor = _make_processor()
mock_session = MagicMock()
count = processor.process_pending_operations_for_user(
"alice", mock_session
)
assert count == 0
def test_operations_for_other_users_not_processed(self):
"""Only processes operations for the specified user."""
processor = _make_processor()
processor.queue_progress_update("bob", "r-300", 50.0)
processor.queue_progress_update("alice", "r-301", 75.0)
mock_session = MagicMock()
mock_research = Mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
count = processor.process_pending_operations_for_user(
"alice", mock_session
)
assert count == 1
assert mock_research.progress == 75.0
# Bob's operation should still be pending
assert len(processor.pending_operations) == 1
def test_exception_triggers_rollback(self):
"""Exception during processing triggers session rollback."""
processor = _make_processor()
processor.queue_progress_update("alice", "r-400", 50.0)
mock_session = MagicMock()
mock_session.query.side_effect = RuntimeError("DB error")
count = processor.process_pending_operations_for_user(
"alice", mock_session
)
assert count == 0
mock_session.rollback.assert_called_once()
def test_error_update_without_report_path(self):
"""Error update without report_path doesn't set report_path."""
processor = _make_processor()
processor.queue_error_update(
username="alice",
research_id="r-401",
status="suspended",
error_message="Timeout",
metadata={},
completed_at="2026-01-01",
)
mock_session = MagicMock()
mock_research = Mock(
spec=[
"status",
"error_message",
"research_meta",
"completed_at",
"report_path",
]
)
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
count = processor.process_pending_operations_for_user(
"alice", mock_session
)
assert count == 1
assert mock_research.status == "suspended"