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

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