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

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
# ===========================================================================