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
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:
@@ -0,0 +1 @@
|
||||
"""Tests for queue processing modules."""
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user