7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
465 lines
15 KiB
Python
465 lines
15 KiB
Python
"""
|
|
Tests for research_service lifecycle management.
|
|
|
|
Tests cover:
|
|
- Resource cleanup
|
|
- Progress callbacks
|
|
"""
|
|
|
|
from unittest.mock import Mock, patch
|
|
import threading
|
|
|
|
|
|
class TestResourceCleanup:
|
|
"""Tests for resource cleanup."""
|
|
|
|
@patch("local_deep_research.settings.env_registry.is_test_mode")
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch("local_deep_research.web.routes.globals.cleanup_research")
|
|
@patch("local_deep_research.web.services.socket_service.SocketIOService")
|
|
def test_cleanup_removes_from_active_dict(
|
|
self,
|
|
mock_socket,
|
|
mock_cleanup,
|
|
mock_queue,
|
|
mock_test_mode,
|
|
):
|
|
"""Cleanup calls cleanup_research to remove from active dicts."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cleanup_research_resources,
|
|
)
|
|
|
|
mock_test_mode.return_value = False
|
|
|
|
cleanup_research_resources(123, "testuser")
|
|
|
|
mock_cleanup.assert_called_once_with(123)
|
|
|
|
@patch("local_deep_research.settings.env_registry.is_test_mode")
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch("local_deep_research.web.routes.globals.cleanup_research")
|
|
@patch("local_deep_research.web.services.socket_service.SocketIOService")
|
|
def test_cleanup_notifies_queue_processor(
|
|
self,
|
|
mock_socket,
|
|
mock_cleanup,
|
|
mock_queue,
|
|
mock_test_mode,
|
|
):
|
|
"""Cleanup notifies queue processor of completion."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cleanup_research_resources,
|
|
)
|
|
|
|
mock_test_mode.return_value = False
|
|
|
|
cleanup_research_resources(123, "testuser")
|
|
|
|
mock_queue.notify_research_completed.assert_called_once_with(
|
|
"testuser", 123, user_password=None
|
|
)
|
|
|
|
def test_cleanup_socket_emit_success(self):
|
|
"""Cleanup emits socket event on success."""
|
|
# Simulate socket emit
|
|
mock_socket = Mock()
|
|
|
|
# Emit to subscribers
|
|
mock_socket.emit_to_subscribers(
|
|
"research_complete",
|
|
123,
|
|
{"status": "completed"},
|
|
)
|
|
|
|
mock_socket.emit_to_subscribers.assert_called_once()
|
|
|
|
@patch("local_deep_research.settings.env_registry.is_test_mode")
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch("local_deep_research.web.routes.globals.cleanup_research")
|
|
@patch("local_deep_research.web.services.socket_service.SocketIOService")
|
|
def test_cleanup_socket_emit_failure_handling(
|
|
self,
|
|
mock_socket_class,
|
|
mock_cleanup,
|
|
mock_queue,
|
|
mock_test_mode,
|
|
):
|
|
"""Cleanup handles socket emit failure gracefully."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cleanup_research_resources,
|
|
)
|
|
|
|
mock_test_mode.return_value = False
|
|
mock_socket = Mock()
|
|
mock_socket.emit_to_subscribers.side_effect = Exception("Socket error")
|
|
mock_socket_class.return_value = mock_socket
|
|
|
|
# Should not raise even with socket error
|
|
cleanup_research_resources(123, "testuser")
|
|
|
|
@patch("local_deep_research.settings.env_registry.is_test_mode")
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch("local_deep_research.web.routes.globals.cleanup_research")
|
|
@patch("local_deep_research.web.services.socket_service.SocketIOService")
|
|
def test_cleanup_database_session_handling(
|
|
self,
|
|
mock_socket,
|
|
mock_cleanup,
|
|
mock_queue,
|
|
mock_test_mode,
|
|
):
|
|
"""Cleanup handles database session properly."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cleanup_research_resources,
|
|
)
|
|
|
|
mock_test_mode.return_value = False
|
|
|
|
# Should complete without database errors
|
|
cleanup_research_resources(123, "testuser")
|
|
|
|
mock_queue.notify_research_completed.assert_called()
|
|
|
|
def test_cleanup_file_handle_closure(self):
|
|
"""Cleanup closes file handles."""
|
|
# Simulate file handle management
|
|
file_handles = []
|
|
|
|
class MockFileHandle:
|
|
def __init__(self):
|
|
self.closed = False
|
|
file_handles.append(self)
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
# Create handles
|
|
MockFileHandle()
|
|
MockFileHandle()
|
|
|
|
# Cleanup closes handles
|
|
for handle in file_handles:
|
|
handle.close()
|
|
|
|
assert all(h.closed for h in file_handles)
|
|
|
|
def test_cleanup_memory_release(self):
|
|
"""Cleanup releases memory references."""
|
|
# Simulate memory management
|
|
large_data = {"data": "x" * 10000}
|
|
active_research = {123: {"results": large_data}}
|
|
|
|
# Cleanup removes references
|
|
del active_research[123]
|
|
|
|
assert 123 not in active_research
|
|
|
|
def test_cleanup_concurrent_access_safety(self):
|
|
"""Cleanup is thread-safe."""
|
|
active_research = {}
|
|
lock = threading.Lock()
|
|
cleanup_count = [0]
|
|
|
|
def cleanup(research_id):
|
|
with lock:
|
|
if research_id in active_research:
|
|
del active_research[research_id]
|
|
cleanup_count[0] += 1
|
|
|
|
# Add some research
|
|
for i in range(10):
|
|
active_research[i] = {"data": i}
|
|
|
|
# Concurrent cleanup
|
|
threads = []
|
|
for i in range(10):
|
|
t = threading.Thread(target=cleanup, args=(i,))
|
|
threads.append(t)
|
|
t.start()
|
|
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert len(active_research) == 0
|
|
assert cleanup_count[0] == 10
|
|
|
|
|
|
class TestProgressCallbacks:
|
|
"""Tests for progress callback functionality."""
|
|
|
|
def test_progress_callback_sequencing(self):
|
|
"""Progress callbacks are called in sequence."""
|
|
calls = []
|
|
|
|
def progress_callback(message, progress, metadata):
|
|
calls.append((message, progress, metadata.get("phase")))
|
|
|
|
# Simulate progress sequence
|
|
progress_callback("Starting", 0, {"phase": "init"})
|
|
progress_callback("Searching", 20, {"phase": "search"})
|
|
progress_callback("Analyzing", 50, {"phase": "analysis"})
|
|
progress_callback("Synthesizing", 80, {"phase": "synthesis"})
|
|
progress_callback("Complete", 100, {"phase": "complete"})
|
|
|
|
assert len(calls) == 5
|
|
assert calls[0][1] == 0
|
|
assert calls[-1][1] == 100
|
|
assert calls[-1][2] == "complete"
|
|
|
|
def test_progress_callback_percentage_accuracy(self):
|
|
"""Progress callbacks report accurate percentages."""
|
|
progress_values = []
|
|
|
|
def progress_callback(message, progress, metadata):
|
|
if progress is not None:
|
|
progress_values.append(progress)
|
|
|
|
# Simulate progress
|
|
for p in [0, 10, 25, 50, 75, 90, 100]:
|
|
progress_callback("Progress", p, {})
|
|
|
|
# All values should be in valid range
|
|
assert all(0 <= p <= 100 for p in progress_values)
|
|
# Values should be non-decreasing (progress doesn't go backwards)
|
|
for i in range(1, len(progress_values)):
|
|
assert progress_values[i] >= progress_values[i - 1]
|
|
|
|
def test_progress_callback_status_message_updates(self):
|
|
"""Progress callbacks include meaningful status messages."""
|
|
messages = []
|
|
|
|
def progress_callback(message, progress, metadata):
|
|
messages.append(message)
|
|
|
|
progress_callback("Starting research process", 5, {"phase": "init"})
|
|
progress_callback("Searching for information", 30, {"phase": "search"})
|
|
progress_callback(
|
|
"Generating summary", 90, {"phase": "output_generation"}
|
|
)
|
|
|
|
assert "Starting" in messages[0]
|
|
assert "Searching" in messages[1]
|
|
assert "summary" in messages[2].lower()
|
|
|
|
@patch("local_deep_research.web.services.socket_service.SocketIOService")
|
|
def test_progress_callback_socket_integration(self, mock_socket_class):
|
|
"""Progress callbacks integrate with socket service."""
|
|
mock_socket = Mock()
|
|
mock_socket_class.return_value = mock_socket
|
|
|
|
def progress_callback(research_id, progress, metadata):
|
|
mock_socket.emit_to_subscribers(
|
|
"progress",
|
|
research_id,
|
|
{"progress": progress, **metadata},
|
|
)
|
|
|
|
progress_callback(123, 50, {"phase": "search"})
|
|
|
|
mock_socket.emit_to_subscribers.assert_called_once_with(
|
|
"progress",
|
|
123,
|
|
{"progress": 50, "phase": "search"},
|
|
)
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
def test_progress_callback_database_update(self, mock_queue):
|
|
"""Progress callbacks queue database updates."""
|
|
|
|
def progress_callback(username, research_id, progress):
|
|
mock_queue.queue_progress_update(username, research_id, progress)
|
|
|
|
progress_callback("testuser", 123, 75)
|
|
|
|
mock_queue.queue_progress_update.assert_called_once_with(
|
|
"testuser", 123, 75
|
|
)
|
|
|
|
def test_progress_callback_rate_limiting(self):
|
|
"""Progress callbacks respect rate limiting."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: DELETE).
|
|
import time
|
|
|
|
last_call_time = [0]
|
|
min_interval = 0.1 # 100ms
|
|
calls_made = []
|
|
|
|
def rate_limited_callback(progress):
|
|
current_time = time.time()
|
|
if current_time - last_call_time[0] >= min_interval:
|
|
calls_made.append(progress)
|
|
last_call_time[0] = current_time
|
|
|
|
# Rapid calls
|
|
for i in range(10):
|
|
rate_limited_callback(i * 10)
|
|
time.sleep(0.05) # 50ms between calls
|
|
|
|
# Only about half should go through due to rate limiting
|
|
assert len(calls_made) < 10
|
|
|
|
def test_progress_callback_error_handling(self):
|
|
"""Progress callbacks handle errors gracefully."""
|
|
errors = []
|
|
|
|
def progress_callback(progress, metadata):
|
|
try:
|
|
if metadata.get("fail"):
|
|
raise ValueError("Callback error")
|
|
except Exception as e:
|
|
errors.append(str(e))
|
|
|
|
# Normal call
|
|
progress_callback(50, {})
|
|
|
|
# Error call
|
|
progress_callback(60, {"fail": True})
|
|
|
|
assert len(errors) == 1
|
|
assert "Callback error" in errors[0]
|
|
|
|
|
|
class TestResearchPhaseTracking:
|
|
"""Tests for research phase tracking."""
|
|
|
|
def test_phase_transition_init_to_search(self):
|
|
"""Phase transitions from init to search."""
|
|
phases = []
|
|
|
|
def track_phase(phase):
|
|
phases.append(phase)
|
|
|
|
track_phase("init")
|
|
track_phase("search")
|
|
|
|
assert phases == ["init", "search"]
|
|
|
|
def test_phase_transition_search_to_analysis(self):
|
|
"""Phase transitions from search to analysis."""
|
|
current_phase = "search"
|
|
next_phase = "analysis"
|
|
|
|
assert current_phase != next_phase
|
|
|
|
def test_phase_transition_analysis_to_synthesis(self):
|
|
"""Phase transitions from analysis to synthesis."""
|
|
phases = ["init", "search", "analysis", "synthesis"]
|
|
|
|
# Synthesis follows analysis
|
|
analysis_idx = phases.index("analysis")
|
|
synthesis_idx = phases.index("synthesis")
|
|
|
|
assert synthesis_idx == analysis_idx + 1
|
|
|
|
def test_phase_transition_synthesis_to_complete(self):
|
|
"""Phase transitions from synthesis to complete."""
|
|
all_phases = [
|
|
"init",
|
|
"search",
|
|
"analysis",
|
|
"synthesis",
|
|
"output_generation",
|
|
"complete",
|
|
]
|
|
|
|
# Complete is the final phase
|
|
assert all_phases[-1] == "complete"
|
|
|
|
def test_phase_metadata_preservation(self):
|
|
"""Phase metadata is preserved across transitions."""
|
|
phase_metadata = {}
|
|
|
|
def update_phase(phase, **kwargs):
|
|
phase_metadata[phase] = kwargs
|
|
|
|
update_phase("search", engine="google", results_count=10)
|
|
update_phase("analysis", findings_count=5)
|
|
update_phase("synthesis", tokens_used=1000)
|
|
|
|
assert phase_metadata["search"]["results_count"] == 10
|
|
assert phase_metadata["analysis"]["findings_count"] == 5
|
|
assert phase_metadata["synthesis"]["tokens_used"] == 1000
|
|
|
|
|
|
class TestActiveResearchManagement:
|
|
"""Tests for active research dictionary management."""
|
|
|
|
def test_active_research_creation(self):
|
|
"""Active research entry is created correctly."""
|
|
active_research = {}
|
|
|
|
active_research[123] = {
|
|
"thread": Mock(),
|
|
"progress": 0,
|
|
"status": "in_progress",
|
|
"log": [],
|
|
"settings": {"model": "gpt-4"},
|
|
}
|
|
|
|
assert 123 in active_research
|
|
assert active_research[123]["status"] == "in_progress"
|
|
|
|
def test_active_research_progress_update(self):
|
|
"""Active research progress is updated correctly."""
|
|
active_research = {123: {"progress": 0, "status": "in_progress"}}
|
|
|
|
active_research[123]["progress"] = 50
|
|
|
|
assert active_research[123]["progress"] == 50
|
|
|
|
def test_active_research_status_transition(self):
|
|
"""Active research status transitions correctly."""
|
|
active_research = {123: {"status": "in_progress"}}
|
|
|
|
# Transition to completed
|
|
active_research[123]["status"] = "completed"
|
|
|
|
assert active_research[123]["status"] == "completed"
|
|
|
|
def test_active_research_removal(self):
|
|
"""Active research entry is removed on cleanup."""
|
|
active_research = {123: {"status": "completed"}}
|
|
|
|
del active_research[123]
|
|
|
|
assert 123 not in active_research
|
|
|
|
def test_active_research_thread_safety(self):
|
|
"""Active research is thread-safe with locking."""
|
|
active_research = {}
|
|
lock = threading.Lock()
|
|
|
|
def add_research(research_id):
|
|
with lock:
|
|
active_research[research_id] = {"status": "in_progress"}
|
|
|
|
def remove_research(research_id):
|
|
with lock:
|
|
if research_id in active_research:
|
|
del active_research[research_id]
|
|
|
|
# Add and remove concurrently
|
|
threads = []
|
|
for i in range(10):
|
|
t1 = threading.Thread(target=add_research, args=(i,))
|
|
threads.append(t1)
|
|
t1.start()
|
|
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# All should be added
|
|
assert len(active_research) == 10
|
|
|
|
# Remove all
|
|
threads = []
|
|
for i in range(10):
|
|
t = threading.Thread(target=remove_research, args=(i,))
|
|
threads.append(t)
|
|
t.start()
|
|
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert len(active_research) == 0
|