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
502 lines
18 KiB
Python
502 lines
18 KiB
Python
"""Coverage tests for metrics/search_tracker.py.
|
|
|
|
Targets uncovered branches: Exception-based error_type, string error_message
|
|
setting unknown_error, outer exception handling in record_search,
|
|
engine stats formatting with actual data, success_rate calculation,
|
|
long/short query truncation in get_search_metrics and get_search_time_series,
|
|
get_research_search_metrics totals and exception path,
|
|
get_search_time_series null fields and exception,
|
|
get_search_tracker Flask credential flow, no request context, exception fallback.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.metrics.search_tracker import (
|
|
SearchTracker,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _mock_session_cm(mock_session):
|
|
"""Return a context-manager mock wrapping *mock_session*."""
|
|
cm = MagicMock()
|
|
cm.__enter__ = Mock(return_value=mock_session)
|
|
cm.__exit__ = Mock(return_value=None)
|
|
return cm
|
|
|
|
|
|
def _setup_db_and_session():
|
|
"""Create a mock db + session pair wired together."""
|
|
mock_db = MagicMock()
|
|
mock_session = MagicMock()
|
|
mock_db.get_session.return_value = _mock_session_cm(mock_session)
|
|
return mock_db, mock_session
|
|
|
|
|
|
def _chain_query(mock_session):
|
|
"""Make session.query() return a fully-chainable mock query."""
|
|
q = MagicMock()
|
|
mock_session.query.return_value = q
|
|
q.filter.return_value = q
|
|
q.filter_by.return_value = q
|
|
q.group_by.return_value = q
|
|
q.order_by.return_value = q
|
|
q.limit.return_value = q
|
|
q.all.return_value = []
|
|
return q
|
|
|
|
|
|
def _base_context():
|
|
return {
|
|
"research_id": "rid-1",
|
|
"research_query": "some research",
|
|
"research_mode": "quick",
|
|
"research_phase": "search",
|
|
"search_iteration": 1,
|
|
"username": "testuser",
|
|
"user_password": "testpass",
|
|
}
|
|
|
|
|
|
# ===========================================================================
|
|
# record_search -- Exception instance extracts type_name
|
|
# ===========================================================================
|
|
|
|
|
|
class TestRecordSearchErrorType:
|
|
"""Cover the error_type derivation branches in record_search."""
|
|
|
|
def _record_with_error(self, error_message, context=None):
|
|
"""Call record_search with the given error_message, returning the
|
|
SearchCall object added to the session."""
|
|
ctx = context or _base_context()
|
|
mock_session = MagicMock()
|
|
mock_cm = _mock_session_cm(mock_session)
|
|
mock_writer = MagicMock()
|
|
mock_writer.get_session.return_value = mock_cm
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.metrics.search_tracker.get_search_context",
|
|
return_value=ctx,
|
|
),
|
|
patch(
|
|
"local_deep_research.database.thread_metrics.metrics_writer",
|
|
mock_writer,
|
|
),
|
|
):
|
|
SearchTracker.record_search(
|
|
engine_name="google",
|
|
query="q",
|
|
success=False,
|
|
error_message=error_message,
|
|
)
|
|
call_obj = mock_session.add.call_args[0][0]
|
|
return call_obj
|
|
|
|
def test_exception_instance_extracts_type_name(self):
|
|
"""When error_message is an Exception, error_type should be its class name."""
|
|
err = ValueError("bad value")
|
|
call_obj = self._record_with_error(err)
|
|
assert call_obj.error_type == "ValueError"
|
|
assert call_obj.error_message == "bad value"
|
|
|
|
def test_string_error_sets_unknown_error(self):
|
|
"""When error_message is a plain string, error_type should be 'unknown_error'."""
|
|
call_obj = self._record_with_error("timeout occurred")
|
|
assert call_obj.error_type == "unknown_error"
|
|
assert call_obj.error_message == "timeout occurred"
|
|
|
|
def test_no_error_message_leaves_fields_none(self):
|
|
"""When error_message is None, error_type and error_message should be None."""
|
|
ctx = _base_context()
|
|
mock_session = MagicMock()
|
|
mock_cm = _mock_session_cm(mock_session)
|
|
mock_writer = MagicMock()
|
|
mock_writer.get_session.return_value = mock_cm
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.metrics.search_tracker.get_search_context",
|
|
return_value=ctx,
|
|
),
|
|
patch(
|
|
"local_deep_research.database.thread_metrics.metrics_writer",
|
|
mock_writer,
|
|
),
|
|
):
|
|
SearchTracker.record_search(
|
|
engine_name="google",
|
|
query="q",
|
|
success=True,
|
|
)
|
|
call_obj = mock_session.add.call_args[0][0]
|
|
assert call_obj.error_type is None
|
|
assert call_obj.error_message is None
|
|
|
|
|
|
class TestRecordSearchSkipPaths:
|
|
"""Cover the early-return skip paths in record_search."""
|
|
|
|
def test_skips_when_context_is_none(self):
|
|
"""No research context -> early return, metrics_writer never invoked."""
|
|
mock_writer = MagicMock()
|
|
with patch(
|
|
"local_deep_research.metrics.search_tracker.get_search_context",
|
|
return_value=None,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.thread_metrics.metrics_writer",
|
|
mock_writer,
|
|
):
|
|
SearchTracker.record_search(
|
|
engine_name="brave",
|
|
query="q",
|
|
)
|
|
mock_writer.get_session.assert_not_called()
|
|
mock_writer.set_user_password.assert_not_called()
|
|
|
|
def test_skips_when_username_missing(self):
|
|
"""Context without username -> warning logged, no session opened."""
|
|
ctx = _base_context()
|
|
ctx["username"] = None
|
|
mock_writer = MagicMock()
|
|
with patch(
|
|
"local_deep_research.metrics.search_tracker.get_search_context",
|
|
return_value=ctx,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.thread_metrics.metrics_writer",
|
|
mock_writer,
|
|
):
|
|
with patch(
|
|
"local_deep_research.metrics.search_tracker.logger"
|
|
) as mock_logger:
|
|
SearchTracker.record_search(
|
|
engine_name="brave",
|
|
query="q",
|
|
)
|
|
mock_writer.get_session.assert_not_called()
|
|
mock_logger.warning.assert_called_once()
|
|
|
|
def test_skips_when_password_missing(self):
|
|
"""Context with username but no password -> early return before writer."""
|
|
ctx = _base_context()
|
|
ctx["user_password"] = None
|
|
mock_writer = MagicMock()
|
|
with patch(
|
|
"local_deep_research.metrics.search_tracker.get_search_context",
|
|
return_value=ctx,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.thread_metrics.metrics_writer",
|
|
mock_writer,
|
|
):
|
|
SearchTracker.record_search(
|
|
engine_name="brave",
|
|
query="q",
|
|
)
|
|
mock_writer.get_session.assert_not_called()
|
|
mock_writer.set_user_password.assert_not_called()
|
|
|
|
|
|
class TestRecordSearchOuterException:
|
|
"""Cover the outer try/except in record_search (line 119)."""
|
|
|
|
def test_outer_exception_caught_gracefully(self):
|
|
"""If get_search_context().get() raises, the outer except catches it."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: FIX_ASSERTION).
|
|
ctx_dict = _base_context()
|
|
bad_ctx = MagicMock()
|
|
bad_ctx.__getitem__ = ctx_dict.__getitem__
|
|
bad_ctx.get = Mock(
|
|
side_effect=[
|
|
ctx_dict["research_id"],
|
|
ctx_dict["research_query"],
|
|
ctx_dict["research_mode"],
|
|
ctx_dict["research_phase"],
|
|
ctx_dict["search_iteration"],
|
|
RuntimeError("boom"),
|
|
]
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.metrics.search_tracker.get_search_context",
|
|
return_value=bad_ctx,
|
|
):
|
|
# Should not raise -- outer except handles it
|
|
SearchTracker.record_search(
|
|
engine_name="brave",
|
|
query="q",
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# get_search_metrics -- engine stats formatting, success_rate, truncation
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetSearchMetricsFormatting:
|
|
"""Cover data formatting paths in get_search_metrics."""
|
|
|
|
def _make_stat(
|
|
self, engine, call_count, avg_rt, total_res, avg_res, succ, err
|
|
):
|
|
return SimpleNamespace(
|
|
search_engine=engine,
|
|
call_count=call_count,
|
|
avg_response_time=avg_rt,
|
|
total_results=total_res,
|
|
avg_results_per_call=avg_res,
|
|
success_count=succ,
|
|
error_count=err,
|
|
)
|
|
|
|
def _make_call(self, engine, query, results_count, rt, status, ts):
|
|
return SimpleNamespace(
|
|
search_engine=engine,
|
|
query=query,
|
|
results_count=results_count,
|
|
response_time_ms=rt,
|
|
success_status=status,
|
|
timestamp=ts,
|
|
)
|
|
|
|
def test_engine_stats_success_rate_calculation(self):
|
|
"""success_rate = success_count / call_count * 100."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
stat = self._make_stat("brave", 10, 200.0, 50, 5.0, 8, 2)
|
|
q.all.side_effect = [[stat], []]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_metrics(period="all", research_mode="all")
|
|
|
|
stats = result["search_engine_stats"]
|
|
assert len(stats) == 1
|
|
assert stats[0]["success_rate"] == 80.0
|
|
assert stats[0]["error_count"] == 2
|
|
assert stats[0]["avg_response_time"] == 200.0
|
|
|
|
def test_engine_stats_zero_call_count(self):
|
|
"""When call_count is 0, success_rate should be 0."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
stat = self._make_stat("duckduckgo", 0, None, None, None, 0, None)
|
|
q.all.side_effect = [[stat], []]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_metrics()
|
|
stats = result["search_engine_stats"]
|
|
assert stats[0]["success_rate"] == 0
|
|
assert stats[0]["avg_response_time"] == 0
|
|
assert stats[0]["total_results"] == 0
|
|
|
|
def test_long_query_truncated_at_100_chars(self):
|
|
"""Queries longer than 100 chars should be truncated with '...'."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
long_query = "a" * 150
|
|
call = self._make_call(
|
|
"google", long_query, 5, 100, "success", "2025-01-01"
|
|
)
|
|
q.all.side_effect = [[], [call]]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_metrics()
|
|
|
|
recent = result["recent_calls"]
|
|
assert len(recent) == 1
|
|
assert recent[0]["query"] == "a" * 100 + "..."
|
|
|
|
def test_short_query_not_truncated(self):
|
|
"""Queries under 100 chars should remain as-is."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
short_query = "short query"
|
|
call = self._make_call(
|
|
"google", short_query, 5, 100, "success", "2025-01-01"
|
|
)
|
|
q.all.side_effect = [[], [call]]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_metrics()
|
|
assert result["recent_calls"][0]["query"] == "short query"
|
|
|
|
def test_none_query_in_recent_calls(self):
|
|
"""A None query should not crash the truncation logic."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
call = self._make_call("google", None, 0, 0, "error", "2025-01-01")
|
|
q.all.side_effect = [[], [call]]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_metrics()
|
|
assert result["recent_calls"][0]["query"] is None
|
|
|
|
|
|
# ===========================================================================
|
|
# get_research_search_metrics -- totals, exception
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetResearchSearchMetrics:
|
|
"""Cover totals calculation and exception path."""
|
|
|
|
def _make_call(self, results, rt_ms, status):
|
|
return SimpleNamespace(
|
|
search_engine="brave",
|
|
query="q",
|
|
results_count=results,
|
|
response_time_ms=rt_ms,
|
|
success_status=status,
|
|
timestamp="2025-01-01",
|
|
)
|
|
|
|
def _make_engine_stat(self, engine, count, avg_rt, total_res, succ):
|
|
return SimpleNamespace(
|
|
search_engine=engine,
|
|
call_count=count,
|
|
avg_response_time=avg_rt,
|
|
total_results=total_res,
|
|
success_count=succ,
|
|
)
|
|
|
|
def test_totals_calculated_correctly(self):
|
|
"""total_searches, total_results, avg_response_time, success_rate."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
calls = [
|
|
self._make_call(10, 200, "success"),
|
|
self._make_call(5, 300, "success"),
|
|
self._make_call(0, 500, "error"),
|
|
]
|
|
estat = self._make_engine_stat("brave", 3, 333.0, 15, 2)
|
|
q.all.side_effect = [calls, [estat]]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_research_search_metrics("rid-1")
|
|
|
|
assert result["total_searches"] == 3
|
|
assert result["total_results"] == 15
|
|
assert result["avg_response_time"] == 333 # round((200+300+500)/3)
|
|
assert result["success_rate"] == pytest.approx(66.7, abs=0.1)
|
|
assert len(result["search_calls"]) == 3
|
|
assert len(result["engine_stats"]) == 1
|
|
|
|
def test_empty_research_returns_zeros(self):
|
|
"""No search calls should produce all-zero totals."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
q.all.side_effect = [[], []]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_research_search_metrics("rid-empty")
|
|
|
|
assert result["total_searches"] == 0
|
|
assert result["total_results"] == 0
|
|
assert result["avg_response_time"] == 0
|
|
assert result["success_rate"] == 0
|
|
|
|
def test_exception_returns_empty_dict(self):
|
|
"""DB exception should return the default empty structure."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
mock_session.query.side_effect = Exception("DB error")
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_research_search_metrics("rid-err")
|
|
|
|
assert result["total_searches"] == 0
|
|
assert result["search_calls"] == []
|
|
assert result["engine_stats"] == []
|
|
|
|
|
|
# ===========================================================================
|
|
# get_search_time_series -- null fields, long query truncation, exception
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetSearchTimeSeries:
|
|
def _make_call(self, ts, engine, results, rt, status, query):
|
|
return SimpleNamespace(
|
|
timestamp=ts,
|
|
search_engine=engine,
|
|
results_count=results,
|
|
response_time_ms=rt,
|
|
success_status=status,
|
|
query=query,
|
|
)
|
|
|
|
def test_null_fields_handled(self):
|
|
"""None timestamp, results_count, response_time_ms should not crash."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
call = self._make_call(None, "google", None, None, "error", "short")
|
|
q.all.return_value = [call]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_time_series()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["timestamp"] is None
|
|
assert result[0]["results_count"] == 0
|
|
assert result[0]["response_time_ms"] == 0
|
|
|
|
def test_long_query_truncated_at_50_chars(self):
|
|
"""Queries > 50 chars should be truncated with '...'."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
long_q = "x" * 80
|
|
call = self._make_call("2025-01-01", "brave", 5, 100, "success", long_q)
|
|
q.all.return_value = [call]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_time_series()
|
|
|
|
assert result[0]["query"] == "x" * 50 + "..."
|
|
|
|
def test_short_query_unchanged(self):
|
|
"""Queries <= 50 chars should not be truncated."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
q = _chain_query(mock_session)
|
|
|
|
call = self._make_call(
|
|
"2025-01-01", "brave", 5, 100, "success", "short q"
|
|
)
|
|
q.all.return_value = [call]
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_time_series()
|
|
|
|
assert result[0]["query"] == "short q"
|
|
|
|
def test_exception_returns_empty_list(self):
|
|
"""DB exception should return []."""
|
|
mock_db, mock_session = _setup_db_and_session()
|
|
mock_session.query.side_effect = Exception("DB boom")
|
|
|
|
tracker = SearchTracker(db=mock_db)
|
|
result = tracker.get_search_time_series()
|
|
|
|
assert result == []
|
|
|
|
|
|
# ===========================================================================
|
|
# get_search_tracker -- Flask credentials, no request context, exception
|
|
# ===========================================================================
|