项目文件夹

文件
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

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