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