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
984 lines
36 KiB
Python
984 lines
36 KiB
Python
"""Tests for log_utils module."""
|
|
|
|
import logging
|
|
import queue
|
|
import threading
|
|
from datetime import datetime
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
|
|
|
|
class TestInterceptHandler:
|
|
"""Tests for InterceptHandler class."""
|
|
|
|
def test_emit_forwards_to_loguru(self):
|
|
"""Should forward log records to loguru."""
|
|
from local_deep_research.utilities.log_utils import InterceptHandler
|
|
|
|
handler = InterceptHandler()
|
|
|
|
# Create a mock log record
|
|
record = logging.LogRecord(
|
|
name="test",
|
|
level=logging.INFO,
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg="Test message",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.logger"
|
|
) as mock_logger:
|
|
mock_logger.level.return_value = Mock(name="INFO")
|
|
mock_opt = Mock()
|
|
mock_logger.opt.return_value = mock_opt
|
|
|
|
handler.emit(record)
|
|
|
|
mock_logger.opt.assert_called()
|
|
mock_opt.log.assert_called()
|
|
|
|
def test_handles_unknown_level(self):
|
|
"""Should handle unknown log levels by using levelno."""
|
|
from local_deep_research.utilities.log_utils import InterceptHandler
|
|
|
|
handler = InterceptHandler()
|
|
|
|
record = logging.LogRecord(
|
|
name="test",
|
|
level=35, # Non-standard level
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg="Test",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
record.levelname = "CUSTOM"
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.logger"
|
|
) as mock_logger:
|
|
mock_logger.level.side_effect = ValueError("Unknown level")
|
|
mock_opt = Mock()
|
|
mock_logger.opt.return_value = mock_opt
|
|
|
|
handler.emit(record)
|
|
|
|
mock_opt.log.assert_called()
|
|
|
|
|
|
class TestLogForResearch:
|
|
"""Tests for log_for_research decorator."""
|
|
|
|
def test_sets_research_id_in_g(self):
|
|
"""Should set research_id in Flask g object."""
|
|
from local_deep_research.utilities.log_utils import log_for_research
|
|
|
|
mock_g = MagicMock()
|
|
|
|
@log_for_research
|
|
def test_func(research_id):
|
|
return "done"
|
|
|
|
with patch("local_deep_research.utilities.log_utils.g", mock_g):
|
|
test_func("test-uuid-123")
|
|
|
|
# Check that research_id was set
|
|
assert mock_g.research_id == "test-uuid-123"
|
|
|
|
def test_removes_research_id_after_function(self):
|
|
"""Should remove research_id from g after function completes."""
|
|
from local_deep_research.utilities.log_utils import log_for_research
|
|
|
|
@log_for_research
|
|
def test_func(research_id):
|
|
return "result"
|
|
|
|
mock_g = MagicMock()
|
|
with patch("local_deep_research.utilities.log_utils.g", mock_g):
|
|
test_func("uuid")
|
|
|
|
mock_g.pop.assert_called_with("research_id")
|
|
|
|
def test_preserves_function_metadata(self):
|
|
"""Should preserve function name and docstring."""
|
|
from local_deep_research.utilities.log_utils import log_for_research
|
|
|
|
@log_for_research
|
|
def documented_func(research_id):
|
|
"""My documentation."""
|
|
pass
|
|
|
|
assert documented_func.__name__ == "documented_func"
|
|
assert documented_func.__doc__ == "My documentation."
|
|
|
|
def test_passes_args_and_kwargs(self):
|
|
"""Should pass arguments correctly."""
|
|
from local_deep_research.utilities.log_utils import log_for_research
|
|
|
|
@log_for_research
|
|
def test_func(research_id, arg1, kwarg1=None):
|
|
return (research_id, arg1, kwarg1)
|
|
|
|
mock_g = MagicMock()
|
|
with patch("local_deep_research.utilities.log_utils.g", mock_g):
|
|
result = test_func("uuid", "value1", kwarg1="value2")
|
|
|
|
assert result == ("uuid", "value1", "value2")
|
|
|
|
|
|
class TestDatabaseSink:
|
|
"""Tests for database_sink function."""
|
|
|
|
def test_creates_log_entry_dict(self):
|
|
"""Should create log entry dictionary from message."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test log message",
|
|
"name": "test_module",
|
|
"function": "test_function",
|
|
"line": 42,
|
|
"level": Mock(name="INFO"),
|
|
# research_id is required — ResearchLog rows are research-scoped,
|
|
# logs with no research context are filtered out at the queue
|
|
# boundary (they'd never reach the panel anyway).
|
|
"extra": {"research_id": "rid-1"},
|
|
}
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.has_app_context",
|
|
return_value=False,
|
|
):
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils._log_queue"
|
|
) as mock_queue:
|
|
database_sink(mock_message)
|
|
|
|
# Should queue the log since we're not in app context
|
|
mock_queue.put_nowait.assert_called_once()
|
|
|
|
def test_queues_log_from_non_main_thread(self):
|
|
"""Should queue log when not in main thread."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test message",
|
|
"name": "module",
|
|
"function": "func",
|
|
"line": 1,
|
|
"level": Mock(name="DEBUG"),
|
|
"extra": {"research_id": "test-uuid"},
|
|
}
|
|
|
|
# Mock has_app_context to return True but thread name is not MainThread
|
|
mock_thread = Mock()
|
|
mock_thread.name = "WorkerThread"
|
|
|
|
with patch.object(module, "has_app_context", return_value=True):
|
|
with patch.object(
|
|
module, "_get_research_id", return_value="test-uuid"
|
|
):
|
|
with patch.object(
|
|
threading, "current_thread", return_value=mock_thread
|
|
):
|
|
with patch.object(module, "_log_queue") as mock_queue:
|
|
database_sink(mock_message)
|
|
|
|
# Should queue since not MainThread
|
|
mock_queue.put_nowait.assert_called_once()
|
|
|
|
def test_handles_full_queue_gracefully(self):
|
|
"""Should not raise when queue is full."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test",
|
|
"name": "mod",
|
|
"function": "f",
|
|
"line": 1,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {},
|
|
}
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.has_app_context",
|
|
return_value=False,
|
|
):
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils._log_queue"
|
|
) as mock_queue:
|
|
mock_queue.put_nowait.side_effect = queue.Full()
|
|
|
|
# Should not raise
|
|
database_sink(mock_message)
|
|
|
|
def test_writes_to_database_in_main_thread(self):
|
|
"""Should write to database when in main thread with app context."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test message",
|
|
"name": "test_module",
|
|
"function": "test_func",
|
|
"line": 42,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {"username": "testuser", "research_id": "test-uuid"},
|
|
}
|
|
|
|
mock_thread = Mock()
|
|
mock_thread.name = "MainThread"
|
|
|
|
mock_session = MagicMock()
|
|
mock_cm = MagicMock()
|
|
mock_cm.__enter__ = Mock(return_value=mock_session)
|
|
mock_cm.__exit__ = Mock(return_value=None)
|
|
|
|
mock_g = Mock()
|
|
mock_g.get.return_value = None
|
|
|
|
with patch.object(module, "has_app_context", return_value=True):
|
|
with patch.object(module, "g", mock_g):
|
|
with patch.object(
|
|
threading, "current_thread", return_value=mock_thread
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session",
|
|
return_value=mock_cm,
|
|
):
|
|
database_sink(mock_message)
|
|
|
|
# Should write to database
|
|
mock_session.add.assert_called_once()
|
|
mock_session.commit.assert_called_once()
|
|
|
|
def test_handles_database_error_gracefully(self):
|
|
"""Should not raise on database errors when writing."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test",
|
|
"name": "mod",
|
|
"function": "f",
|
|
"line": 1,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {"research_id": "test-uuid"},
|
|
}
|
|
|
|
mock_thread = Mock()
|
|
mock_thread.name = "MainThread"
|
|
|
|
mock_g = Mock()
|
|
mock_g.get.return_value = None
|
|
|
|
with patch.object(module, "has_app_context", return_value=True):
|
|
with patch.object(module, "g", mock_g):
|
|
with patch.object(
|
|
threading, "current_thread", return_value=mock_thread
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session",
|
|
side_effect=Exception("DB error"),
|
|
):
|
|
# Should not raise
|
|
database_sink(mock_message)
|
|
|
|
def test_extracts_research_id_from_record_extra(self):
|
|
"""Should extract research_id from record extra."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test",
|
|
"name": "mod",
|
|
"function": "f",
|
|
"line": 1,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {"research_id": "record-uuid"},
|
|
}
|
|
|
|
with patch.object(module, "has_app_context", return_value=False):
|
|
with patch.object(module, "_log_queue") as mock_queue:
|
|
database_sink(mock_message)
|
|
|
|
# Verify the queued log entry contains the research_id
|
|
call_args = mock_queue.put_nowait.call_args[0][0]
|
|
assert call_args["research_id"] == "record-uuid"
|
|
|
|
def test_extracts_research_id_from_flask_g(self):
|
|
"""Should extract research_id from Flask g when not in record."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test",
|
|
"name": "mod",
|
|
"function": "f",
|
|
"line": 1,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {},
|
|
}
|
|
|
|
mock_g = Mock()
|
|
mock_g.get.return_value = "flask-uuid"
|
|
|
|
with patch.object(module, "has_app_context", return_value=True):
|
|
with patch.object(module, "g", mock_g):
|
|
with patch.object(module, "_log_queue") as mock_queue:
|
|
# Use non-main thread to queue instead of write
|
|
mock_thread = Mock()
|
|
mock_thread.name = "WorkerThread"
|
|
with patch.object(
|
|
threading, "current_thread", return_value=mock_thread
|
|
):
|
|
database_sink(mock_message)
|
|
|
|
# Verify the queued log entry contains the research_id
|
|
call_args = mock_queue.put_nowait.call_args[0][0]
|
|
assert call_args["research_id"] == "flask-uuid"
|
|
|
|
def test_record_research_id_takes_priority_over_flask(self):
|
|
"""Record research_id should take priority over Flask g."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test",
|
|
"name": "mod",
|
|
"function": "f",
|
|
"line": 1,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {"research_id": "record-uuid"},
|
|
}
|
|
|
|
mock_g = Mock()
|
|
mock_g.get.return_value = "flask-uuid"
|
|
|
|
with patch.object(module, "has_app_context", return_value=True):
|
|
with patch.object(module, "g", mock_g):
|
|
with patch.object(module, "_log_queue") as mock_queue:
|
|
# Use non-main thread to queue instead of write
|
|
mock_thread = Mock()
|
|
mock_thread.name = "WorkerThread"
|
|
with patch.object(
|
|
threading, "current_thread", return_value=mock_thread
|
|
):
|
|
database_sink(mock_message)
|
|
|
|
# Record research_id should win
|
|
call_args = mock_queue.put_nowait.call_args[0][0]
|
|
assert call_args["research_id"] == "record-uuid"
|
|
|
|
def test_captures_user_password_from_thread_context(self):
|
|
"""Should snapshot user_password from per-thread research context onto
|
|
the queue entry, so the daemon can decrypt the per-user SQLCipher DB
|
|
from a thread that can't read the research thread's ContextVar."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "Test",
|
|
"name": "mod",
|
|
"function": "f",
|
|
"line": 1,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {"research_id": "rid-1"},
|
|
}
|
|
|
|
fake_ctx = {
|
|
"research_id": "rid-1",
|
|
"username": "alice",
|
|
"user_password": "pw-from-thread-ctx", # gitleaks:allow
|
|
}
|
|
|
|
with patch.object(
|
|
module, "_get_research_context_fallback", return_value=fake_ctx
|
|
):
|
|
with patch.object(module, "has_app_context", return_value=False):
|
|
with patch.object(module, "_log_queue") as mock_queue:
|
|
database_sink(mock_message)
|
|
|
|
call_args = mock_queue.put_nowait.call_args[0][0]
|
|
assert call_args["username"] == "alice"
|
|
assert call_args["user_password"] == "pw-from-thread-ctx"
|
|
|
|
def test_skips_persistence_when_no_research_or_username(self):
|
|
"""Logs without a research_id AND without a username must be dropped
|
|
at the sink, not queued. ResearchLog is research-scoped — system
|
|
DEBUG logs (auth, settings, etc.) attached via flask_session would
|
|
churn through the queue and never resolve to a valid row."""
|
|
from local_deep_research.utilities.log_utils import database_sink
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": "system debug log",
|
|
"name": "mod",
|
|
"function": "f",
|
|
"line": 1,
|
|
"level": Mock(name="DEBUG"),
|
|
"extra": {},
|
|
}
|
|
|
|
# No research context anywhere — neither thread context nor Flask.
|
|
with patch.object(
|
|
module, "_get_research_context_fallback", return_value=None
|
|
):
|
|
with patch.object(module, "has_app_context", return_value=False):
|
|
with patch.object(module, "_log_queue") as mock_queue:
|
|
database_sink(mock_message)
|
|
|
|
mock_queue.put_nowait.assert_not_called()
|
|
|
|
|
|
class TestTruncateForDatabase:
|
|
"""Tests for _truncate_for_database helper and database_sink integration.
|
|
|
|
Mirrors the cap discipline of _truncate_for_frontend: a long message is
|
|
cut at DATABASE_MESSAGE_MAX_LENGTH chars and a short indicator is
|
|
appended that reports the original length. Stops langgraph fetch logs
|
|
(which inline 10 KB page bodies) from being persisted in full into
|
|
ResearchLog rows.
|
|
"""
|
|
|
|
def test_short_message_passes_through_unchanged(self):
|
|
from local_deep_research.utilities.log_utils import (
|
|
DATABASE_MESSAGE_MAX_LENGTH,
|
|
_truncate_for_database,
|
|
)
|
|
|
|
short = "small payload"
|
|
assert len(short) < DATABASE_MESSAGE_MAX_LENGTH
|
|
assert _truncate_for_database(short) == short
|
|
|
|
def test_message_at_exact_cap_is_not_truncated(self):
|
|
"""The cap is inclusive — a message of exactly the cap length must
|
|
pass through unchanged."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
DATABASE_MESSAGE_MAX_LENGTH,
|
|
_truncate_for_database,
|
|
)
|
|
|
|
exact = "Y" * DATABASE_MESSAGE_MAX_LENGTH
|
|
out = _truncate_for_database(exact)
|
|
assert out == exact
|
|
assert "truncated" not in out
|
|
|
|
def test_long_message_truncated_with_indicator(self):
|
|
from local_deep_research.utilities.log_utils import (
|
|
DATABASE_MESSAGE_MAX_LENGTH,
|
|
_truncate_for_database,
|
|
)
|
|
|
|
big = "X" * (DATABASE_MESSAGE_MAX_LENGTH + 5000)
|
|
out = _truncate_for_database(big)
|
|
assert out.startswith("X" * DATABASE_MESSAGE_MAX_LENGTH)
|
|
assert "truncated" in out
|
|
assert str(len(big)) in out # original length is surfaced
|
|
# Indicator overhead is bounded (~100 chars), well under the cap.
|
|
assert len(out) < DATABASE_MESSAGE_MAX_LENGTH + 200
|
|
|
|
def test_database_sink_queues_truncated_message(self):
|
|
"""database_sink must apply the cap BEFORE queueing so the 10 KB
|
|
blob never sits in _log_queue (bounded to 1000 entries; without the
|
|
cap, that's a 10 MB worst-case transient)."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
DATABASE_MESSAGE_MAX_LENGTH,
|
|
database_sink,
|
|
)
|
|
|
|
big = "Z" * (DATABASE_MESSAGE_MAX_LENGTH + 1000)
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"time": datetime.now(),
|
|
"message": big,
|
|
"name": "test_module",
|
|
"function": "test_function",
|
|
"line": 1,
|
|
"level": Mock(name="INFO"),
|
|
"extra": {"research_id": "rid-truncate"},
|
|
}
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.has_app_context",
|
|
return_value=False,
|
|
):
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils._log_queue"
|
|
) as mock_queue:
|
|
database_sink(mock_message)
|
|
|
|
mock_queue.put_nowait.assert_called_once()
|
|
queued = mock_queue.put_nowait.call_args[0][0]
|
|
# The dict put on the queue carries the truncated string, not the
|
|
# original 10KB+ blob.
|
|
assert len(queued["message"]) < len(big)
|
|
assert queued["message"].startswith("Z" * DATABASE_MESSAGE_MAX_LENGTH)
|
|
assert "truncated" in queued["message"]
|
|
|
|
|
|
class TestFrontendProgressSink:
|
|
"""Tests for frontend_progress_sink function."""
|
|
|
|
def test_skips_when_no_research_id(self):
|
|
"""Should skip when no research_id is available."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
frontend_progress_sink,
|
|
)
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"message": "Test",
|
|
"level": Mock(name="INFO"),
|
|
"time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")),
|
|
"extra": {},
|
|
}
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils._get_research_id",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.SocketIOService"
|
|
) as mock_socket:
|
|
frontend_progress_sink(mock_message)
|
|
|
|
# Should not emit anything
|
|
mock_socket.return_value.emit_to_subscribers.assert_not_called()
|
|
|
|
def test_emits_to_subscribers_with_research_id(self):
|
|
"""Should emit to subscribers when research_id is present."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
frontend_progress_sink,
|
|
)
|
|
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"message": "Progress update",
|
|
"level": Mock(name="INFO"),
|
|
"time": Mock(isoformat=Mock(return_value="2024-01-01T12:00:00")),
|
|
"extra": {"research_id": "test-uuid"},
|
|
}
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils._get_research_id",
|
|
return_value="test-uuid",
|
|
):
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.SocketIOService"
|
|
) as mock_socket:
|
|
frontend_progress_sink(mock_message)
|
|
|
|
mock_socket.return_value.emit_to_subscribers.assert_called_once()
|
|
call_args = (
|
|
mock_socket.return_value.emit_to_subscribers.call_args
|
|
)
|
|
assert call_args[0][0] == "progress"
|
|
assert call_args[0][1] == "test-uuid"
|
|
|
|
def test_short_messages_pass_through_unchanged(self):
|
|
"""Messages under the cap must reach the wire byte-for-byte."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
FRONTEND_MESSAGE_MAX_LENGTH,
|
|
frontend_progress_sink,
|
|
)
|
|
|
|
short_msg = "small payload"
|
|
assert len(short_msg) < FRONTEND_MESSAGE_MAX_LENGTH
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"message": short_msg,
|
|
"level": Mock(name="INFO"),
|
|
"time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")),
|
|
"extra": {"research_id": "rid"},
|
|
}
|
|
with (
|
|
patch(
|
|
"local_deep_research.utilities.log_utils._get_research_id",
|
|
return_value="rid",
|
|
),
|
|
patch(
|
|
"local_deep_research.utilities.log_utils.SocketIOService"
|
|
) as mock_socket,
|
|
):
|
|
frontend_progress_sink(mock_message)
|
|
payload = mock_socket.return_value.emit_to_subscribers.call_args[0][2]
|
|
assert payload["log_entry"]["message"] == short_msg
|
|
|
|
def test_long_messages_truncated_with_indicator(self):
|
|
"""Messages exceeding the cap must be truncated and carry a clear
|
|
indicator that points the user at the server logs for the full
|
|
text. Other log levels / sinks are unaffected by this sink."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
FRONTEND_MESSAGE_MAX_LENGTH,
|
|
frontend_progress_sink,
|
|
)
|
|
|
|
big_msg = "X" * (FRONTEND_MESSAGE_MAX_LENGTH + 5000)
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"message": big_msg,
|
|
"level": Mock(name="INFO"),
|
|
"time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")),
|
|
"extra": {"research_id": "rid"},
|
|
}
|
|
with (
|
|
patch(
|
|
"local_deep_research.utilities.log_utils._get_research_id",
|
|
return_value="rid",
|
|
),
|
|
patch(
|
|
"local_deep_research.utilities.log_utils.SocketIOService"
|
|
) as mock_socket,
|
|
):
|
|
frontend_progress_sink(mock_message)
|
|
|
|
payload = mock_socket.return_value.emit_to_subscribers.call_args[0][2]
|
|
out = payload["log_entry"]["message"]
|
|
# Truncated to the cap plus indicator
|
|
assert out.startswith("X" * FRONTEND_MESSAGE_MAX_LENGTH)
|
|
assert "truncated" in out
|
|
assert str(len(big_msg)) in out # original length surfaced
|
|
# Sanity: cap + indicator is still much smaller than the original
|
|
assert len(out) < len(big_msg)
|
|
|
|
def test_message_at_exact_cap_is_not_truncated(self):
|
|
"""A message of exactly ``FRONTEND_MESSAGE_MAX_LENGTH`` chars
|
|
must pass through unchanged — the cap is inclusive."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
FRONTEND_MESSAGE_MAX_LENGTH,
|
|
frontend_progress_sink,
|
|
)
|
|
|
|
exact = "Y" * FRONTEND_MESSAGE_MAX_LENGTH
|
|
mock_message = Mock()
|
|
mock_message.record = {
|
|
"message": exact,
|
|
"level": Mock(name="INFO"),
|
|
"time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")),
|
|
"extra": {"research_id": "rid"},
|
|
}
|
|
with (
|
|
patch(
|
|
"local_deep_research.utilities.log_utils._get_research_id",
|
|
return_value="rid",
|
|
),
|
|
patch(
|
|
"local_deep_research.utilities.log_utils.SocketIOService"
|
|
) as mock_socket,
|
|
):
|
|
frontend_progress_sink(mock_message)
|
|
payload = mock_socket.return_value.emit_to_subscribers.call_args[0][2]
|
|
assert payload["log_entry"]["message"] == exact
|
|
assert "truncated" not in payload["log_entry"]["message"]
|
|
|
|
|
|
class TestFlushLogQueue:
|
|
"""Tests for flush_log_queue function."""
|
|
|
|
def test_flushes_all_queued_logs(self):
|
|
"""Should flush all logs from queue."""
|
|
from local_deep_research.utilities.log_utils import flush_log_queue
|
|
import local_deep_research.utilities.log_utils as module
|
|
|
|
log_entries = [
|
|
{
|
|
"timestamp": datetime.now(),
|
|
"message": "Log 1",
|
|
"module": "mod",
|
|
"function": "f",
|
|
"line_no": 1,
|
|
"level": "INFO",
|
|
"research_id": None,
|
|
"username": None,
|
|
},
|
|
{
|
|
"timestamp": datetime.now(),
|
|
"message": "Log 2",
|
|
"module": "mod",
|
|
"function": "f",
|
|
"line_no": 2,
|
|
"level": "INFO",
|
|
"research_id": None,
|
|
"username": None,
|
|
},
|
|
]
|
|
|
|
mock_session = MagicMock()
|
|
mock_cm = MagicMock()
|
|
mock_cm.__enter__ = Mock(return_value=mock_session)
|
|
mock_cm.__exit__ = Mock(return_value=None)
|
|
|
|
with patch.object(module, "_log_queue") as mock_queue:
|
|
mock_queue.empty.side_effect = [False, False, True]
|
|
mock_queue.get_nowait.side_effect = log_entries + [queue.Empty()]
|
|
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session",
|
|
return_value=mock_cm,
|
|
):
|
|
flush_log_queue()
|
|
|
|
# Should have written 2 logs
|
|
assert mock_session.add.call_count == 2
|
|
assert mock_session.commit.call_count == 2
|
|
|
|
def test_handles_empty_queue(self):
|
|
"""Should handle empty queue gracefully."""
|
|
from local_deep_research.utilities.log_utils import flush_log_queue
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils._log_queue"
|
|
) as mock_queue:
|
|
mock_queue.empty.return_value = True
|
|
|
|
# Should not raise
|
|
flush_log_queue()
|
|
|
|
|
|
class TestConfigLogger:
|
|
"""Tests for config_logger function."""
|
|
|
|
def test_configures_logger(self):
|
|
"""Should configure logger with sinks."""
|
|
from local_deep_research.utilities.log_utils import config_logger
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.logger"
|
|
) as mock_logger:
|
|
config_logger("test_app")
|
|
|
|
mock_logger.enable.assert_called_with("local_deep_research")
|
|
mock_logger.remove.assert_called_once()
|
|
# Should add multiple sinks
|
|
assert mock_logger.add.call_count >= 3
|
|
|
|
def test_adds_file_logging_when_enabled(self):
|
|
"""Should add file logging when environment variable is set."""
|
|
from local_deep_research.utilities.log_utils import config_logger
|
|
|
|
with patch.dict("os.environ", {"LDR_ENABLE_FILE_LOGGING": "true"}):
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.logger"
|
|
) as mock_logger:
|
|
config_logger("test_app")
|
|
|
|
# Should add 4 sinks (stderr, database, frontend, file)
|
|
assert mock_logger.add.call_count >= 4
|
|
|
|
def test_creates_milestone_level(self):
|
|
"""Should create MILESTONE log level."""
|
|
from local_deep_research.utilities.log_utils import config_logger
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.logger"
|
|
) as mock_logger:
|
|
config_logger("test_app")
|
|
|
|
mock_logger.level.assert_called()
|
|
|
|
def test_handles_existing_milestone_level(self):
|
|
"""Should handle case where MILESTONE level already exists."""
|
|
from local_deep_research.utilities.log_utils import config_logger
|
|
|
|
with patch(
|
|
"local_deep_research.utilities.log_utils.logger"
|
|
) as mock_logger:
|
|
mock_logger.level.side_effect = ValueError("Level already exists")
|
|
|
|
# Should not raise
|
|
config_logger("test_app")
|
|
|
|
|
|
class TestWriteLogToDatabase:
|
|
"""Tests for _write_log_to_database function."""
|
|
|
|
def test_writes_research_log(self):
|
|
"""Should write ResearchLog to database."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
_write_log_to_database,
|
|
)
|
|
|
|
log_entry = {
|
|
"timestamp": datetime.now(),
|
|
"message": "Test message",
|
|
"module": "test_module",
|
|
"function": "test_func",
|
|
"line_no": 42,
|
|
"level": "INFO",
|
|
"research_id": "test-uuid",
|
|
"username": "testuser",
|
|
}
|
|
|
|
mock_session = MagicMock()
|
|
mock_cm = MagicMock()
|
|
mock_cm.__enter__ = Mock(return_value=mock_session)
|
|
mock_cm.__exit__ = Mock(return_value=None)
|
|
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session",
|
|
return_value=mock_cm,
|
|
):
|
|
_write_log_to_database(log_entry)
|
|
|
|
mock_session.add.assert_called_once()
|
|
mock_session.commit.assert_called_once()
|
|
|
|
def test_handles_database_error_gracefully(self):
|
|
"""Should not raise on database errors."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
_write_log_to_database,
|
|
)
|
|
|
|
log_entry = {
|
|
"timestamp": datetime.now(),
|
|
"message": "Test",
|
|
"module": "mod",
|
|
"function": "f",
|
|
"line_no": 1,
|
|
"level": "INFO",
|
|
"research_id": None,
|
|
"username": None,
|
|
}
|
|
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session",
|
|
side_effect=Exception("DB error"),
|
|
):
|
|
# Should not raise
|
|
_write_log_to_database(log_entry)
|
|
|
|
def test_passes_user_password_to_get_user_db_session(self):
|
|
"""The password snapshotted onto the queue entry by database_sink
|
|
must be forwarded to get_user_db_session as the password= kwarg.
|
|
Without this the daemon thread cannot decrypt the per-user
|
|
SQLCipher DB and every research-thread MILESTONE log is silently
|
|
dropped."""
|
|
from local_deep_research.utilities.log_utils import (
|
|
_write_log_to_database,
|
|
)
|
|
|
|
log_entry = {
|
|
"timestamp": datetime.now(),
|
|
"message": "milestone msg",
|
|
"module": "mod",
|
|
"function": "f",
|
|
"line_no": 1,
|
|
"level": "MILESTONE",
|
|
"research_id": "rid-1",
|
|
"username": "alice",
|
|
"user_password": "pw-from-queue", # gitleaks:allow
|
|
}
|
|
|
|
mock_session = MagicMock()
|
|
mock_cm = MagicMock()
|
|
mock_cm.__enter__ = Mock(return_value=mock_session)
|
|
mock_cm.__exit__ = Mock(return_value=None)
|
|
|
|
with patch(
|
|
"local_deep_research.database.session_context.get_user_db_session",
|
|
return_value=mock_cm,
|
|
) as mock_get_session:
|
|
_write_log_to_database(log_entry)
|
|
|
|
mock_get_session.assert_called_once()
|
|
call_args = mock_get_session.call_args
|
|
# username is positional (first arg).
|
|
assert call_args.args[0] == "alice"
|
|
# password must be passed as the kwarg the daemon uses for
|
|
# SQLCipher decryption.
|
|
assert call_args.kwargs.get("password") == "pw-from-queue"
|
|
|
|
|
|
class TestLogQueueProcessorDaemon:
|
|
"""Tests for start_log_queue_processor / stop_log_queue_processor."""
|
|
|
|
def _make_fake_app(self):
|
|
"""Return a Flask-app-like stub that supports ``with app.app_context()``."""
|
|
fake_app = MagicMock()
|
|
ctx = MagicMock()
|
|
ctx.__enter__ = Mock(return_value=None)
|
|
ctx.__exit__ = Mock(return_value=False)
|
|
fake_app.app_context.return_value = ctx
|
|
return fake_app
|
|
|
|
def test_spawns_daemon_thread(self):
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (OK).
|
|
from local_deep_research.utilities import log_utils
|
|
|
|
app = self._make_fake_app()
|
|
try:
|
|
thread = log_utils.start_log_queue_processor(app)
|
|
assert isinstance(thread, threading.Thread)
|
|
assert thread.daemon is True
|
|
assert thread.is_alive()
|
|
assert thread.name == "log-queue-processor"
|
|
finally:
|
|
log_utils.stop_log_queue_processor(timeout=2.0)
|
|
|
|
def test_is_idempotent(self):
|
|
from local_deep_research.utilities import log_utils
|
|
|
|
app = self._make_fake_app()
|
|
try:
|
|
first = log_utils.start_log_queue_processor(app)
|
|
second = log_utils.start_log_queue_processor(app)
|
|
assert first is second
|
|
finally:
|
|
log_utils.stop_log_queue_processor(timeout=2.0)
|
|
|
|
def test_stop_joins_thread(self):
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (OK).
|
|
from local_deep_research.utilities import log_utils
|
|
|
|
app = self._make_fake_app()
|
|
thread = log_utils.start_log_queue_processor(app)
|
|
log_utils.stop_log_queue_processor(timeout=2.0)
|
|
assert not thread.is_alive()
|
|
|
|
def test_stop_does_not_clear_ref_when_join_times_out(self):
|
|
"""If the daemon doesn't exit before join() times out, the
|
|
module-level reference must stay populated — otherwise a
|
|
subsequent start would happily spawn a second daemon competing
|
|
for the same queue."""
|
|
from local_deep_research.utilities import log_utils
|
|
|
|
# Stand-in thread that ignores the stop signal so join() always
|
|
# times out. Daemon=True so it doesn't keep the test process alive.
|
|
live_thread = threading.Thread(
|
|
target=lambda: threading.Event().wait(), daemon=True
|
|
)
|
|
live_thread.start()
|
|
log_utils._queue_processor_thread = live_thread
|
|
try:
|
|
log_utils.stop_log_queue_processor(timeout=0.05)
|
|
assert log_utils._queue_processor_thread is live_thread, (
|
|
"thread reference cleared despite join timing out — would "
|
|
"let start_log_queue_processor spawn a duplicate daemon"
|
|
)
|
|
finally:
|
|
log_utils._queue_processor_thread = None
|