"""Unit tests for metrics_routes pure logic: _extract_domain, get_rating_analytics, get_link_analytics. These test the aggregation logic directly (mocking DB), not through HTTP endpoints. """ from unittest.mock import MagicMock, Mock, patch from local_deep_research.database.models import ( ResearchHistory, ) from local_deep_research.domain_classifier import DomainClassification from local_deep_research.web.routes.metrics_routes import ( _extract_domain, get_link_analytics, get_rating_analytics, ) # --------------------------------------------------------------------------- # _extract_domain # --------------------------------------------------------------------------- class TestExtractDomain: """Tests for _extract_domain URL normalization.""" def test_normal_url(self): assert _extract_domain("https://example.com/path") == "example.com" def test_www_prefix_stripped(self): assert _extract_domain("https://www.example.com") == "example.com" def test_no_scheme_empty_netloc(self): # urlparse("example.com") → netloc="", path="example.com" assert _extract_domain("example.com") is None def test_url_with_port(self): assert ( _extract_domain("https://example.com:8080/path") == "example.com:8080" ) def test_none_returns_none(self): """None input should be treated like other invalid URLs.""" assert _extract_domain(None) is None def test_empty_string_returns_none(self): assert _extract_domain("") is None def test_uppercase_normalized(self): assert _extract_domain("https://EXAMPLE.COM/path") == "example.com" def test_www_with_subdomain(self): # Only leading "www." is stripped, not "www2." assert ( _extract_domain("https://www.sub.example.com") == "sub.example.com" ) def test_http_scheme(self): assert _extract_domain("http://example.com/page?q=1") == "example.com" # --------------------------------------------------------------------------- # get_rating_analytics — pure logic # --------------------------------------------------------------------------- def _make_rating(value): """Create a mock ResearchRating with a .rating attribute.""" r = Mock() r.rating = value return r def _mock_session_ctx(mock_ctx, session): """Wire up get_user_db_session mock as context manager.""" mock_ctx.return_value.__enter__ = Mock(return_value=session) mock_ctx.return_value.__exit__ = Mock(return_value=False) class TestGetRatingAnalyticsPureLogic: """Tests for get_rating_analytics aggregation, mocking DB layer.""" @patch("local_deep_research.web.routes.metrics_routes.flask_session", {}) @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_no_username_returns_error(self, mock_ctx): result = get_rating_analytics(username=None) assert result["rating_analytics"]["error"] == "No user session" assert result["rating_analytics"]["total_ratings"] == 0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_empty_ratings(self, mock_ctx): session = MagicMock() session.query.return_value.filter.return_value.all.return_value = [] _mock_session_ctx(mock_ctx, session) result = get_rating_analytics(period="30d", username="alice") analytics = result["rating_analytics"] assert analytics["avg_rating"] is None assert analytics["total_ratings"] == 0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_rating_distribution(self, mock_ctx): ratings = [_make_rating(v) for v in [5, 5, 4, 3]] session = MagicMock() session.query.return_value.filter.return_value.all.return_value = ( ratings ) _mock_session_ctx(mock_ctx, session) result = get_rating_analytics(period="30d", username="alice") dist = result["rating_analytics"]["rating_distribution"] assert dist["5"] == 2 assert dist["4"] == 1 assert dist["3"] == 1 assert dist["2"] == 0 assert dist["1"] == 0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_average_rating(self, mock_ctx): ratings = [_make_rating(v) for v in [5, 4, 3]] session = MagicMock() session.query.return_value.filter.return_value.all.return_value = ( ratings ) _mock_session_ctx(mock_ctx, session) result = get_rating_analytics(period="30d", username="alice") assert result["rating_analytics"]["avg_rating"] == 4.0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_satisfaction_mapping(self, mock_ctx): ratings = [_make_rating(v) for v in [5, 4, 3, 2, 1]] session = MagicMock() session.query.return_value.filter.return_value.all.return_value = ( ratings ) _mock_session_ctx(mock_ctx, session) result = get_rating_analytics(period="30d", username="alice") sat = result["rating_analytics"]["satisfaction_stats"] assert sat["very_satisfied"] == 1 assert sat["satisfied"] == 1 assert sat["neutral"] == 1 assert sat["dissatisfied"] == 1 assert sat["very_dissatisfied"] == 1 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_period_all_no_time_filter(self, mock_ctx): """period='all' → days=None → no time filter applied.""" ratings = [_make_rating(5)] session = MagicMock() query_mock = session.query.return_value # When days is None, .all() is called directly (no .filter()) query_mock.all.return_value = ratings _mock_session_ctx(mock_ctx, session) result = get_rating_analytics(period="all", username="alice") assert result["rating_analytics"]["total_ratings"] == 1 # filter should NOT be called when period is "all" query_mock.filter.assert_not_called() @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_unknown_period_defaults_to_30(self, mock_ctx): """Unknown period string falls back to 30 days.""" session = MagicMock() session.query.return_value.filter.return_value.all.return_value = [] _mock_session_ctx(mock_ctx, session) # Should not raise — just uses default 30 result = get_rating_analytics(period="999d", username="alice") assert result["rating_analytics"]["total_ratings"] == 0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_exception_returns_fallback(self, mock_ctx): """Any exception → returns zero-count fallback dict.""" mock_ctx.return_value.__enter__ = Mock( side_effect=RuntimeError("db error") ) mock_ctx.return_value.__exit__ = Mock(return_value=False) result = get_rating_analytics(period="30d", username="alice") analytics = result["rating_analytics"] assert analytics["avg_rating"] is None assert analytics["total_ratings"] == 0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_total_ratings_count(self, mock_ctx): ratings = [_make_rating(v) for v in [5, 5, 4, 4, 3]] session = MagicMock() session.query.return_value.filter.return_value.all.return_value = ( ratings ) _mock_session_ctx(mock_ctx, session) result = get_rating_analytics(period="7d", username="bob") assert result["rating_analytics"]["total_ratings"] == 5 # --------------------------------------------------------------------------- # get_link_analytics — pure logic # --------------------------------------------------------------------------- def _build_link_session(resources, classifications=None, researches=None): """Build a mock session that handles multiple session.query(Model) calls. get_link_analytics calls session.query with different models: 1. ResearchResource → filter → all → resources 2. DomainClassification → filter (in_) → all → classifications 3. ResearchHistory → filter (in_) → all → researches """ if classifications is None: classifications = [] if researches is None: researches = [] # Create separate query chain mocks for each model resource_query = MagicMock() resource_query.filter.return_value.all.return_value = resources resource_query.all.return_value = resources classification_query = MagicMock() classification_query.filter.return_value.all.return_value = classifications research_query = MagicMock() research_query.filter.return_value.all.return_value = researches session = MagicMock() def query_dispatch(*entities): # ResearchResource is now queried by projected columns (id/url/... + # a has_preview label) rather than the full entity (#4560), so match # the other two models by identity and default to the resource query. first = entities[0] if first is DomainClassification: return classification_query if first is ResearchHistory: return research_query return resource_query session.query.side_effect = query_dispatch return session def _make_resource( url, research_id=1, title=None, preview=None, created_at="2025-01-15T12:00:00", source_type=None, ): r = Mock() r.url = url r.research_id = research_id r.title = title r.content_preview = preview # get_link_analytics now reads the SQL-projected ``has_preview`` boolean # (content_preview IS NOT NULL AND != '') rather than the body itself (#4560). r.has_preview = bool(preview) r.created_at = created_at r.source_type = source_type return r class TestGetLinkAnalyticsPureLogic: """Tests for get_link_analytics aggregation, mocking DB layer.""" @patch("local_deep_research.web.routes.metrics_routes.flask_session", {}) @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_no_username_returns_error(self, mock_ctx): result = get_link_analytics(username=None) assert result["link_analytics"]["error"] == "No user session" assert result["link_analytics"]["total_links"] == 0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_empty_resources(self, mock_ctx): session = _build_link_session(resources=[]) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") la = result["link_analytics"] assert la["top_domains"] == [] assert la["total_unique_domains"] == 0 assert la["total_links"] == 0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_domain_counting(self, mock_ctx): resources = [ _make_resource("https://example.com/a", research_id=1), _make_resource("https://example.com/b", research_id=1), _make_resource("https://example.com/c", research_id=2), _make_resource("https://other.com/d", research_id=2), ] session = _build_link_session(resources=resources) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") la = result["link_analytics"] assert la["total_links"] == 4 # example.com should be the top domain with count=3 top = la["top_domains"] assert len(top) == 2 assert top[0]["domain"] == "example.com" assert top[0]["count"] == 3 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_avg_links_per_research(self, mock_ctx): # 6 resources across 3 researches → avg = 2.0 resources = [ _make_resource(f"https://d{i}.com/p", research_id=(i % 3) + 1) for i in range(6) ] session = _build_link_session(resources=resources) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") assert result["link_analytics"]["avg_links_per_research"] == 2.0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_temporal_date_slicing(self, mock_ctx): resources = [ _make_resource("https://a.com/1", created_at="2025-01-15T12:00:00"), _make_resource("https://a.com/2", created_at="2025-01-15T14:00:00"), _make_resource("https://a.com/3", created_at="2025-01-16T10:00:00"), ] session = _build_link_session(resources=resources) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") trend = result["link_analytics"]["temporal_trend"] # Two dates: 2025-01-15 (count=2) and 2025-01-16 (count=1) assert len(trend) == 2 assert trend[0]["date"] == "2025-01-15" assert trend[0]["count"] == 2 assert trend[1]["date"] == "2025-01-16" assert trend[1]["count"] == 1 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_top_10_domain_limit(self, mock_ctx): # Create 12 unique domains resources = [ _make_resource(f"https://domain{i}.com/p") for i in range(12) ] session = _build_link_session(resources=resources) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") # Should only return top 10 assert len(result["link_analytics"]["top_domains"]) == 10 assert result["link_analytics"]["total_unique_domains"] == 12 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_percentage_calculation(self, mock_ctx): resources = [ _make_resource("https://a.com/1"), _make_resource("https://a.com/2"), _make_resource("https://b.com/1"), _make_resource("https://b.com/2"), ] session = _build_link_session(resources=resources) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") top = result["link_analytics"]["top_domains"] # Each domain has 2/4 = 50% for d in top: assert d["percentage"] == 50.0 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_source_type_tracking(self, mock_ctx): resources = [ _make_resource("https://a.com/1", source_type="academic"), _make_resource("https://b.com/1", source_type="academic"), _make_resource("https://c.com/1", source_type="news"), ] session = _build_link_session(resources=resources) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") src = result["link_analytics"]["source_type_analysis"] assert src["academic"] == 2 assert src["news"] == 1 @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session") def test_unique_domains_count(self, mock_ctx): resources = [ _make_resource("https://a.com/1"), _make_resource("https://a.com/2"), _make_resource("https://b.com/1"), _make_resource("https://c.com/1"), ] session = _build_link_session(resources=resources) _mock_session_ctx(mock_ctx, session) result = get_link_analytics(period="30d", username="alice") assert result["link_analytics"]["total_unique_domains"] == 3