learningcircuit--local-deep-research
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
399 行
16 KiB
Python
399 行
16 KiB
Python
"""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
|