chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for queue processing modules."""
+415
View File
@@ -0,0 +1,415 @@
"""
Tests for the QueueProcessorV2 class.
Tests cover:
- Initialization
- Starting and stopping
- User activity notification
- Research queuing
- Completion notifications
- Error handling
"""
import threading
from unittest.mock import Mock, MagicMock, patch
class TestQueueProcessorV2Init:
"""Tests for QueueProcessorV2 initialization."""
def test_init_default_interval(self):
"""Initializes with default check interval."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
assert processor.check_interval == 10
assert processor.running is False
assert processor.thread is None
def test_init_custom_interval(self):
"""Initializes with custom check interval."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2(check_interval=30)
assert processor.check_interval == 30
def test_init_creates_empty_user_set(self):
"""Initializes with empty users to check set."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
assert len(processor._users_to_check) == 0
def test_init_creates_empty_pending_operations(self):
"""Initializes with empty pending operations dict."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
assert processor.pending_operations == {}
class TestQueueProcessorV2StartStop:
"""Tests for start and stop methods."""
def test_start_sets_running_flag(self):
"""start sets running flag to True."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
with patch.object(processor, "_process_queue_loop"):
processor.start()
assert processor.running is True
# Clean up
processor.running = False
if processor.thread:
processor.thread.join(timeout=1)
def test_start_creates_thread(self):
"""start creates a daemon thread."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
with patch.object(processor, "_process_queue_loop"):
processor.start()
assert processor.thread is not None
assert processor.thread.daemon is True
# Clean up
processor.running = False
processor.thread.join(timeout=1)
def test_start_when_already_running(self):
"""start does nothing if already running."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
processor.running = True
# Should not create a new thread
processor.start()
assert processor.thread is None
def test_stop_sets_running_flag_false(self):
"""stop sets running flag to False."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
processor.running = True
processor.thread = Mock()
processor.thread.join = Mock()
processor.stop()
assert processor.running is False
processor.thread.join.assert_called_once_with(timeout=10)
def test_stop_interrupts_check_interval_wait(self):
"""stop returns promptly while the loop sleeps between iterations.
The autouse reset_singletons fixture stops the processor after
every app test, so a stop() that blocks for check_interval adds
that many seconds of teardown to each of those tests (observed
as uniform 10.00s teardowns that pushed CI past its job timeout).
"""
import time
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2(check_interval=30)
# Wrap the real wait so the test knows when the loop has entered
# it — deterministic, unlike sleeping and hoping the first
# iteration has finished.
loop_entered_wait = threading.Event()
real_wait = processor._stop_event.wait
def tracking_wait(timeout=None):
loop_entered_wait.set()
return real_wait(timeout)
processor._stop_event.wait = tracking_wait
processor.start()
try:
assert loop_entered_wait.wait(timeout=10), (
"loop never reached its inter-iteration wait"
)
started = time.monotonic()
processor.stop()
elapsed = time.monotonic() - started
assert elapsed < 5, (
f"stop() blocked for {elapsed:.1f}s; the loop's "
"inter-iteration wait was not interrupted"
)
assert not processor.thread.is_alive()
finally:
processor.stop()
def test_start_after_stop_rearms_loop_wait(self):
"""A restarted processor loops again instead of exiting instantly.
stop() leaves _stop_event set; start() must clear it or the
restarted loop's wait returns immediately every iteration
(busy-spin) — and create_app() does restart this singleton after
the test-suite teardown stops it.
"""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
processor.start()
processor.stop()
with patch.object(processor, "_process_queue_loop"):
processor.start()
try:
assert not processor._stop_event.is_set()
finally:
processor.stop()
class TestQueueProcessorV2NotifyUserActivity:
"""Tests for notify_user_activity method."""
def test_notify_user_activity_adds_to_set(self):
"""notify_user_activity adds user to check set."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
processor.notify_user_activity("testuser", "session123")
assert ("testuser", "session123") in processor._users_to_check
def test_notify_user_activity_thread_safe(self):
"""notify_user_activity is thread safe."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
# Add from multiple threads
threads = []
for i in range(10):
t = threading.Thread(
target=processor.notify_user_activity,
args=(f"user{i}", f"session{i}"),
)
threads.append(t)
t.start()
for t in threads:
t.join()
# All should be added
assert len(processor._users_to_check) == 10
class TestQueueProcessorV2NotifyResearchQueued:
"""Tests for notify_research_queued method."""
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
def test_notify_research_queued_queues_task(self, mock_get_session):
"""notify_research_queued adds task to queue."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
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(
"local_deep_research.web.queue.processor_v2.UserQueueService"
) as mock_queue_service_class:
mock_queue_service = Mock()
mock_queue_service_class.return_value = mock_queue_service
processor.notify_research_queued("testuser", "research123")
mock_queue_service.add_task_metadata.assert_called_once()
class TestQueueProcessorV2NotifyResearchCompleted:
"""Tests for notify_research_completed method."""
def test_notify_research_completed_removes_from_active(self):
"""notify_research_completed removes user from active set."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
processor._users_to_check.add("testuser:session123")
# Complete should trigger next queue check
processor.notify_research_completed("testuser", 123)
# Method exists and doesn't crash
class TestQueueProcessorV2QueueOperations:
"""Tests for queue operation methods."""
def test_queue_error_update_stores_pending(self):
"""queue_error_update stores pending operation."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
processor.queue_error_update(
username="testuser",
research_id="123",
status="failed",
error_message="Test error",
metadata={"key": "value"},
completed_at="2024-01-01T00:00:00",
)
# Should store in pending operations
assert len(processor.pending_operations) > 0
# Check operation details
operation = list(processor.pending_operations.values())[0]
assert operation["operation_type"] == "error_update"
assert operation["username"] == "testuser"
assert operation["status"] == "failed"
def test_queue_progress_update_stores_pending(self):
"""queue_progress_update stores pending operation."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
processor.queue_progress_update(
username="testuser",
research_id="123",
progress=0.5,
)
# Should store in pending operations
assert len(processor.pending_operations) > 0
class TestQueueProcessorV2PendingOperations:
"""Tests for pending operations handling."""
def test_pending_operations_thread_safe(self):
"""Pending operations are thread safe."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
# Add operations from multiple threads using progress updates (simpler signature)
threads = []
for i in range(10):
t = threading.Thread(
target=processor.queue_progress_update,
kwargs={
"username": f"user{i}",
"research_id": str(i),
"progress": 0.5,
},
)
threads.append(t)
t.start()
for t in threads:
t.join()
# All should be stored
assert len(processor.pending_operations) == 10
class TestQueueProcessorV2DirectExecution:
"""Tests for direct execution mode."""
@patch("local_deep_research.web.queue.processor_v2.session_password_store")
@patch("local_deep_research.web.queue.processor_v2.db_manager")
@patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
def test_direct_execution_checks_queue_mode(
self, mock_get_session, mock_db_manager, mock_password_store
):
"""Direct execution checks user's queue_mode setting."""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2()
mock_password_store.get_session_password.return_value = "password123"
mock_db_manager.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
with patch(
"local_deep_research.web.queue.processor_v2.UserQueueService"
) as mock_queue:
mock_queue_instance = Mock()
mock_queue.return_value = mock_queue_instance
# Direct execution requires settings manager
with patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings:
mock_settings_instance = Mock()
mock_settings_instance.get_setting.side_effect = (
lambda key, default: (
"queue" if key == "app.queue_mode" else default
)
)
mock_settings.return_value = mock_settings_instance
processor.notify_research_queued(
"testuser",
"research123",
session_id="session456",
query="test query",
)
# Should fall back to queue mode
mock_queue_instance.add_task_metadata.assert_called_once()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,525 @@
"""
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
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
"""High-value tests for web/queue module and storage factory.
Covers gaps not addressed by existing tests:
- Storage factory session validation
- QueueManager basic operations
"""
import pytest
from unittest.mock import MagicMock, patch
from local_deep_research.storage.factory import (
get_report_storage,
)
class TestStorageFactorySessionValidation:
"""Test the storage factory session validation."""
def test_raises_when_session_is_none(self):
"""get_report_storage raises ValueError when session is None."""
with pytest.raises(ValueError, match="Database session is required"):
get_report_storage(session=None)
@patch("local_deep_research.storage.factory.DatabaseWithFileBackupStorage")
@patch("local_deep_research.storage.factory.get_setting_from_snapshot")
def test_explicit_false_skips_settings_lookup(
self, mock_get_setting, mock_storage_cls
):
"""enable_file_backup=False never calls get_setting_from_snapshot."""
mock_session = MagicMock()
get_report_storage(session=mock_session, enable_file_backup=False)
mock_get_setting.assert_not_called()
@patch("local_deep_research.storage.factory.DatabaseWithFileBackupStorage")
@patch("local_deep_research.storage.factory.get_setting_from_snapshot")
def test_explicit_true_skips_settings_lookup(
self, mock_get_setting, mock_storage_cls
):
"""enable_file_backup=True never calls get_setting_from_snapshot."""
mock_session = MagicMock()
get_report_storage(session=mock_session, enable_file_backup=True)
mock_get_setting.assert_not_called()
@patch("local_deep_research.storage.factory.DatabaseWithFileBackupStorage")
def test_factory_propagates_session_to_storage(self, mock_storage_cls):
"""Session is passed through to DatabaseWithFileBackupStorage."""
mock_session = MagicMock()
get_report_storage(session=mock_session, enable_file_backup=False)
mock_storage_cls.assert_called_once_with(
session=mock_session, enable_file_storage=False
)
class TestQueueManagerBasics:
"""Test web/queue/manager.py basic operations."""
def test_manager_has_expected_methods(self):
"""QueueManager has expected public methods."""
from local_deep_research.web.queue.manager import QueueManager
assert hasattr(QueueManager, "add_to_queue")
assert hasattr(QueueManager, "remove_from_queue")
assert hasattr(QueueManager, "get_queue_position")
assert hasattr(QueueManager, "get_user_queue")
def test_queue_manager_add_to_queue_signature(self):
"""add_to_queue method exists and is callable."""
from local_deep_research.web.queue.manager import QueueManager
import inspect
sig = inspect.signature(QueueManager.add_to_queue)
# Should have self plus other params
assert len(sig.parameters) > 1
+346
View File
@@ -0,0 +1,346 @@
"""
Tests for web/queue/manager.py
Tests cover:
- QueueManager.add_to_queue
- QueueManager.get_queue_position
- QueueManager.remove_from_queue
- QueueManager.get_user_queue
"""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.database.session_context import DatabaseSessionError
def _mock_user_db_session(mock_session):
"""Create a mock context manager that yields the given session."""
@contextmanager
def _ctx(username=None, password=None):
yield mock_session
return _ctx
def _mock_user_db_session_error():
"""Create a mock context manager that raises DatabaseSessionError."""
@contextmanager
def _ctx(username=None, password=None):
raise DatabaseSessionError("No database connection")
yield # noqa: unreachable — needed for generator syntax
return _ctx
class TestQueueManagerAddToQueue:
"""Tests for QueueManager.add_to_queue method."""
def test_add_to_queue_success(self):
"""Test successful addition to queue."""
mock_session = MagicMock()
# Mock query chain for max position
mock_query = MagicMock()
mock_query.filter_by.return_value.scalar.return_value = 0
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
with patch(
"local_deep_research.web.queue.manager.queue_processor"
) as mock_processor:
from local_deep_research.web.queue.manager import (
QueueManager,
)
result = QueueManager.add_to_queue(
username="testuser",
research_id="test-id-123",
query="test query",
mode="detailed",
settings={"key": "value"},
)
assert result == 1
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
mock_processor.notify_research_queued.assert_called_once_with(
"testuser", "test-id-123"
)
def test_add_to_queue_no_connection(self):
"""Test add_to_queue raises DatabaseSessionError when no connection."""
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session_error(),
):
from local_deep_research.web.queue.manager import QueueManager
with pytest.raises(DatabaseSessionError):
QueueManager.add_to_queue(
username="testuser",
research_id="test-id-123",
query="test query",
mode="detailed",
settings={},
)
def test_add_to_queue_with_existing_items(self):
"""Test add_to_queue with existing items in queue."""
mock_session = MagicMock()
# Mock query chain for max position - already has 3 items
mock_query = MagicMock()
mock_query.filter_by.return_value.scalar.return_value = 3
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
with patch("local_deep_research.web.queue.manager.queue_processor"):
from local_deep_research.web.queue.manager import (
QueueManager,
)
result = QueueManager.add_to_queue(
username="testuser",
research_id="test-id-456",
query="another query",
mode="quick",
settings={},
)
# Position should be 4 (max + 1)
assert result == 4
class TestQueueManagerGetQueuePosition:
"""Tests for QueueManager.get_queue_position method."""
def test_get_queue_position_success(self):
"""Test getting queue position successfully."""
mock_session = MagicMock()
# Mock queued research item
mock_queued = MagicMock()
mock_queued.position = 3
# Setup query chain
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_queued
mock_filter.count.return_value = 2 # 2 items ahead
mock_query.filter_by.return_value = mock_filter
mock_query.filter.return_value = mock_filter
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
from local_deep_research.web.queue.manager import QueueManager
result = QueueManager.get_queue_position(
username="testuser", research_id="test-id-123"
)
# Position should be ahead_count + 1
assert result == 3
def test_get_queue_position_not_found(self):
"""Test get_queue_position when research not in queue."""
mock_session = MagicMock()
mock_query = MagicMock()
mock_query.filter_by.return_value.first.return_value = None
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
from local_deep_research.web.queue.manager import QueueManager
result = QueueManager.get_queue_position(
username="testuser", research_id="nonexistent"
)
assert result is None
def test_get_queue_position_no_connection(self):
"""Test get_queue_position raises DatabaseSessionError when no connection."""
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session_error(),
):
from local_deep_research.web.queue.manager import QueueManager
with pytest.raises(DatabaseSessionError):
QueueManager.get_queue_position(
username="testuser", research_id="test-id-123"
)
class TestQueueManagerRemoveFromQueue:
"""Tests for QueueManager.remove_from_queue method."""
def test_remove_from_queue_success(self):
"""Test successful removal from queue."""
mock_session = MagicMock()
# Mock queued research item
mock_queued = MagicMock()
mock_queued.position = 2
mock_query = MagicMock()
mock_query.filter_by.return_value.first.return_value = mock_queued
mock_query.filter.return_value.update.return_value = None
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
from local_deep_research.web.queue.manager import QueueManager
result = QueueManager.remove_from_queue(
username="testuser", research_id="test-id-123"
)
assert result is True
mock_session.delete.assert_called_once_with(mock_queued)
mock_session.commit.assert_called_once()
def test_remove_from_queue_not_found(self):
"""Test remove_from_queue when research not in queue."""
mock_session = MagicMock()
mock_query = MagicMock()
mock_query.filter_by.return_value.first.return_value = None
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
from local_deep_research.web.queue.manager import QueueManager
result = QueueManager.remove_from_queue(
username="testuser", research_id="nonexistent"
)
assert result is False
def test_remove_from_queue_no_connection(self):
"""Test remove_from_queue raises DatabaseSessionError when no connection."""
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session_error(),
):
from local_deep_research.web.queue.manager import QueueManager
with pytest.raises(DatabaseSessionError):
QueueManager.remove_from_queue(
username="testuser", research_id="test-id-123"
)
class TestQueueManagerGetUserQueue:
"""Tests for QueueManager.get_user_queue method."""
def test_get_user_queue_success(self):
"""Test getting user queue successfully."""
mock_session = MagicMock()
# Mock queued items
from datetime import datetime
mock_item1 = MagicMock()
mock_item1.research_id = "id-1"
mock_item1.query = "query 1"
mock_item1.mode = "detailed"
mock_item1.position = 1
mock_item1.created_at = datetime(2025, 1, 1, 12, 0, 0)
mock_item1.is_processing = False
mock_item2 = MagicMock()
mock_item2.research_id = "id-2"
mock_item2.query = "query 2"
mock_item2.mode = "quick"
mock_item2.position = 2
mock_item2.created_at = None
mock_item2.is_processing = True
mock_research1 = MagicMock()
mock_research2 = MagicMock()
def query_side_effect(model):
q = MagicMock()
if "QueuedResearch" in str(model):
q.filter_by.return_value.order_by.return_value.all.return_value = [
mock_item1,
mock_item2,
]
else: # ResearchHistory
# Return research on first call, then second
q.filter_by.return_value.first.side_effect = [
mock_research1,
mock_research2,
]
return q
mock_session.query.side_effect = query_side_effect
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
from local_deep_research.web.queue.manager import QueueManager
result = QueueManager.get_user_queue(username="testuser")
assert len(result) == 2
assert result[0]["research_id"] == "id-1"
assert result[0]["query"] == "query 1"
assert result[0]["mode"] == "detailed"
assert result[0]["position"] == 1
assert result[0]["is_processing"] is False
assert result[1]["research_id"] == "id-2"
assert result[1]["created_at"] is None
def test_get_user_queue_empty(self):
"""Test get_user_queue when queue is empty."""
mock_session = MagicMock()
mock_query = MagicMock()
mock_query.filter_by.return_value.order_by.return_value.all.return_value = []
mock_session.query.return_value = mock_query
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session(mock_session),
):
from local_deep_research.web.queue.manager import QueueManager
result = QueueManager.get_user_queue(username="testuser")
assert result == []
def test_get_user_queue_no_connection(self):
"""Test get_user_queue raises DatabaseSessionError when no connection."""
with patch(
"local_deep_research.web.queue.manager.get_user_db_session",
_mock_user_db_session_error(),
):
from local_deep_research.web.queue.manager import QueueManager
with pytest.raises(DatabaseSessionError):
QueueManager.get_user_queue(username="testuser")
@@ -0,0 +1,236 @@
"""Coverage tests for web/queue/manager.py — QueueManager static methods."""
from contextlib import contextmanager
from datetime import datetime
from unittest.mock import MagicMock, patch
MODULE = "local_deep_research.web.queue.manager"
def _fake_session_ctx(session=None):
if session is None:
session = MagicMock()
@contextmanager
def ctx(username=None):
yield session
return ctx
def _make_queued(research_id="r1", position=1, query="q", mode="quick"):
q = MagicMock()
q.research_id = research_id
q.query = query
q.mode = mode
q.position = position
q.created_at = datetime(2025, 1, 1)
q.is_processing = False
return q
def _make_research(id="r1"):
r = MagicMock()
r.id = id
return r
class TestAddToQueue:
def test_success(self):
from local_deep_research.web.queue.manager import QueueManager
ms = MagicMock()
ms.query.return_value.filter_by.return_value.scalar.return_value = 3
with (
patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)),
patch(f"{MODULE}.queue_processor"),
patch("local_deep_research.notifications.send_queue_notification"),
patch("local_deep_research.settings.SettingsManager"),
):
pos = QueueManager.add_to_queue("user", "r1", "query", "quick", {})
assert pos == 4
ms.add.assert_called_once()
ms.commit.assert_called_once()
def test_first_item_empty_queue(self):
from local_deep_research.web.queue.manager import QueueManager
ms = MagicMock()
ms.query.return_value.filter_by.return_value.scalar.return_value = None
with (
patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)),
patch(f"{MODULE}.queue_processor"),
patch("local_deep_research.notifications.send_queue_notification"),
patch("local_deep_research.settings.SettingsManager"),
):
assert (
QueueManager.add_to_queue("user", "r1", "q", "quick", {}) == 1
)
def test_notification_exception_swallowed(self):
from local_deep_research.web.queue.manager import QueueManager
ms = MagicMock()
ms.query.return_value.filter_by.return_value.scalar.return_value = 0
with (
patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)),
patch(f"{MODULE}.queue_processor"),
patch(
"local_deep_research.notifications.send_queue_notification",
side_effect=RuntimeError("notify fail"),
),
patch("local_deep_research.settings.SettingsManager"),
):
assert (
QueueManager.add_to_queue("user", "r1", "q", "quick", {}) == 1
)
def test_queue_processor_exception_swallowed(self):
from local_deep_research.web.queue.manager import QueueManager
ms = MagicMock()
ms.query.return_value.filter_by.return_value.scalar.return_value = 0
mock_qp = MagicMock()
mock_qp.notify_research_queued.side_effect = RuntimeError("qp fail")
with (
patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)),
patch(f"{MODULE}.queue_processor", mock_qp),
patch("local_deep_research.notifications.send_queue_notification"),
patch("local_deep_research.settings.SettingsManager"),
):
assert (
QueueManager.add_to_queue("user", "r1", "q", "quick", {}) == 1
)
class TestGetQueuePosition:
def test_found(self):
from local_deep_research.web.queue.manager import QueueManager
queued = _make_queued(position=5)
ms = MagicMock()
query_mock = MagicMock()
ms.query.return_value = query_mock
query_mock.filter_by.return_value.first.return_value = queued
query_mock.filter.return_value.count.return_value = 2
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
assert QueueManager.get_queue_position("user", "r1") == 3
def test_not_found(self):
from local_deep_research.web.queue.manager import QueueManager
ms = MagicMock()
ms.query.return_value.filter_by.return_value.first.return_value = None
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
assert QueueManager.get_queue_position("user", "nope") is None
def test_first_in_queue(self):
from local_deep_research.web.queue.manager import QueueManager
queued = _make_queued(position=1)
ms = MagicMock()
query_mock = MagicMock()
ms.query.return_value = query_mock
query_mock.filter_by.return_value.first.return_value = queued
query_mock.filter.return_value.count.return_value = 0
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
assert QueueManager.get_queue_position("user", "r1") == 1
class TestRemoveFromQueue:
def test_found_and_removed(self):
from local_deep_research.web.queue.manager import QueueManager
queued = _make_queued(position=2)
ms = MagicMock()
ms.query.return_value.filter_by.return_value.first.return_value = queued
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
assert QueueManager.remove_from_queue("user", "r1") is True
ms.delete.assert_called_once_with(queued)
ms.commit.assert_called_once()
def test_not_found(self):
from local_deep_research.web.queue.manager import QueueManager
ms = MagicMock()
ms.query.return_value.filter_by.return_value.first.return_value = None
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
assert QueueManager.remove_from_queue("user", "nope") is False
class TestGetUserQueue:
def test_with_items(self):
from local_deep_research.web.queue.manager import QueueManager
queued = _make_queued(research_id="r1", position=1)
research = _make_research(id="r1")
ms = MagicMock()
query_mock = MagicMock()
ms.query.return_value = query_mock
query_mock.filter_by.return_value.order_by.return_value.all.return_value = [
queued
]
query_mock.filter_by.return_value.first.return_value = research
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
result = QueueManager.get_user_queue("user")
assert len(result) == 1
assert result[0]["research_id"] == "r1"
def test_empty_queue(self):
from local_deep_research.web.queue.manager import QueueManager
ms = MagicMock()
ms.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = []
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
assert QueueManager.get_user_queue("user") == []
def test_missing_research_skipped(self):
from local_deep_research.web.queue.manager import QueueManager
queued = _make_queued(research_id="r1", position=1)
ms = MagicMock()
query_mock = MagicMock()
ms.query.return_value = query_mock
query_mock.filter_by.return_value.order_by.return_value.all.return_value = [
queued
]
query_mock.filter_by.return_value.first.return_value = None
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
assert QueueManager.get_user_queue("user") == []
def test_item_without_created_at(self):
from local_deep_research.web.queue.manager import QueueManager
queued = _make_queued(research_id="r1", position=1)
queued.created_at = None
research = _make_research(id="r1")
ms = MagicMock()
query_mock = MagicMock()
ms.query.return_value = query_mock
query_mock.filter_by.return_value.order_by.return_value.all.return_value = [
queued
]
query_mock.filter_by.return_value.first.return_value = research
with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
result = QueueManager.get_user_queue("user")
assert result[0]["created_at"] is None