geo/backend/tests/test_services/test_seo_diagnosis.py

845 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
SEO诊断服务单元测试
"""
import pytest
from app.services.diagnosis.seo_diagnosis import (
SEODiagnosisService,
SEODiagnosisResult,
SEODimensionScore,
DiagnosisItem,
SEORecommendation,
DiagnosisStatus,
DimensionName,
TechnicalSEOData,
OnPageSEOData,
ContentQualityData,
BacklinkData,
UserExperienceData,
diagnose_technical_seo,
diagnose_on_page_seo,
diagnose_content_quality,
diagnose_backlinks,
diagnose_user_experience,
generate_recommendations,
)
class TestDiagnosisStatus:
"""诊断状态枚举测试"""
def test_status_values(self):
"""测试状态值"""
assert DiagnosisStatus.PASS == "pass"
assert DiagnosisStatus.WARNING == "warning"
assert DiagnosisStatus.FAIL == "fail"
class TestDimensionName:
"""维度名称枚举测试"""
def test_dimension_names(self):
"""测试维度名称"""
assert DimensionName.TECHNICAL_SEO == "技术SEO"
assert DimensionName.ON_PAGE_SEO == "页面SEO"
assert DimensionName.CONTENT_QUALITY == "内容质量"
assert DimensionName.BACKLINK_ANALYSIS == "外链分析"
assert DimensionName.USER_EXPERIENCE == "用户体验"
class TestDiagnosisItem:
"""诊断项数据结构测试"""
def test_create_item(self):
"""测试创建诊断项"""
item = DiagnosisItem(
name="测试项",
status=DiagnosisStatus.PASS,
description="测试描述",
suggestion="测试建议",
score=1.0,
)
assert item.name == "测试项"
assert item.status == DiagnosisStatus.PASS
assert item.score == 1.0
def test_item_with_details(self):
"""测试带详情的诊断项"""
item = DiagnosisItem(
name="测试项",
status=DiagnosisStatus.WARNING,
description="测试描述",
suggestion="测试建议",
details={"key": "value"},
)
assert item.details == {"key": "value"}
class TestSEODimensionScore:
"""维度评分数据结构测试"""
def test_create_dimension_score(self):
"""测试创建维度评分"""
dim = SEODimensionScore(
name="测试维度",
score=20.0,
max_score=25.0,
items=[],
status=DiagnosisStatus.PASS,
)
assert dim.score == 20.0
assert dim.max_score == 25.0
assert dim.percentage == 80.0
def test_percentage_calculation(self):
"""测试得分率计算"""
dim = SEODimensionScore(
name="测试维度",
score=15.0,
max_score=25.0,
items=[],
status=DiagnosisStatus.PASS,
)
assert dim.percentage == 60.0
def test_status_calculation_all_pass(self):
"""测试全部通过时的状态"""
items = [
DiagnosisItem(name="项1", status=DiagnosisStatus.PASS, description="", suggestion=""),
DiagnosisItem(name="项2", status=DiagnosisStatus.PASS, description="", suggestion=""),
]
dim = SEODimensionScore(
name="测试维度",
score=10.0,
max_score=10.0,
items=items,
status=DiagnosisStatus.PASS,
)
assert dim.status == DiagnosisStatus.PASS
def test_status_calculation_with_warnings(self):
"""测试有警告时的状态"""
items = [
DiagnosisItem(name="项1", status=DiagnosisStatus.PASS, description="", suggestion=""),
DiagnosisItem(name="项2", status=DiagnosisStatus.WARNING, description="", suggestion=""),
DiagnosisItem(name="项3", status=DiagnosisStatus.WARNING, description="", suggestion=""),
]
dim = SEODimensionScore(
name="测试维度",
score=7.0,
max_score=10.0,
items=items,
status=DiagnosisStatus.PASS,
)
assert dim.status == DiagnosisStatus.WARNING
def test_status_calculation_with_fails(self):
"""测试有失败时的状态"""
items = [
DiagnosisItem(name="项1", status=DiagnosisStatus.FAIL, description="", suggestion=""),
DiagnosisItem(name="项2", status=DiagnosisStatus.PASS, description="", suggestion=""),
DiagnosisItem(name="项3", status=DiagnosisStatus.PASS, description="", suggestion=""),
DiagnosisItem(name="项4", status=DiagnosisStatus.PASS, description="", suggestion=""),
]
dim = SEODimensionScore(
name="测试维度",
score=7.0,
max_score=10.0,
items=items,
status=DiagnosisStatus.PASS,
)
# 1个FAIL在4个项中占25%未超过30%但有FAIL所以是WARNING
assert dim.status == DiagnosisStatus.WARNING
def test_status_calculation_many_fails(self):
"""测试大量失败时的状态"""
items = [
DiagnosisItem(name="项1", status=DiagnosisStatus.FAIL, description="", suggestion=""),
DiagnosisItem(name="项2", status=DiagnosisStatus.FAIL, description="", suggestion=""),
DiagnosisItem(name="项3", status=DiagnosisStatus.FAIL, description="", suggestion=""),
DiagnosisItem(name="项4", status=DiagnosisStatus.PASS, description="", suggestion=""),
]
dim = SEODimensionScore(
name="测试维度",
score=5.0,
max_score=10.0,
items=items,
status=DiagnosisStatus.PASS,
)
assert dim.status == DiagnosisStatus.FAIL
class TestSEODiagnosisResult:
"""诊断结果数据结构测试"""
def test_create_result(self):
"""测试创建诊断结果"""
result = SEODiagnosisResult(
overall_score=75.0,
dimensions=[],
recommendations=[],
)
assert result.overall_score == 75.0
assert result.health_level == "good"
def test_health_level_excellent(self):
"""测试优秀等级"""
result = SEODiagnosisResult(
overall_score=85.0,
dimensions=[],
recommendations=[],
)
assert result.health_level == "excellent"
def test_health_level_good(self):
"""测试良好等级"""
result = SEODiagnosisResult(
overall_score=70.0,
dimensions=[],
recommendations=[],
)
assert result.health_level == "good"
def test_health_level_pass(self):
"""测试及格等级"""
result = SEODiagnosisResult(
overall_score=50.0,
dimensions=[],
recommendations=[],
)
assert result.health_level == "pass"
def test_health_level_danger(self):
"""测试危险等级"""
result = SEODiagnosisResult(
overall_score=30.0,
dimensions=[],
recommendations=[],
)
assert result.health_level == "danger"
def test_score_clamping(self):
"""测试分数限制"""
result = SEODiagnosisResult(
overall_score=150.0,
dimensions=[],
recommendations=[],
)
assert result.overall_score == 100.0
result = SEODiagnosisResult(
overall_score=-10.0,
dimensions=[],
recommendations=[],
)
assert result.overall_score == 0.0
def test_to_dict(self):
"""测试字典转换"""
result = SEODiagnosisResult(
overall_score=75.0,
dimensions=[],
recommendations=[],
)
d = result.to_dict()
assert d["overall_score"] == 75.0
assert d["health_level"] == "good"
assert d["health_level_label"] == "良好"
assert "dimensions" in d
assert "recommendations" in d
class TestTechnicalSEODiagnosis:
"""技术SEO诊断测试"""
def test_perfect_technical_seo(self):
"""测试完美技术SEO"""
data = TechnicalSEOData(
is_indexed=True,
crawl_errors=0,
lcp_seconds=2.0,
fid_ms=50.0,
cls_score=0.05,
has_robots_txt=True,
robots_txt_blocks_important=False,
has_sitemap=True,
sitemap_valid=True,
url_structure_normalized=True,
)
result = diagnose_technical_seo(data)
assert result.score == result.max_score
assert result.status == DiagnosisStatus.PASS
def test_indexed_fail(self):
"""测试未索引情况"""
data = TechnicalSEOData(is_indexed=False)
result = diagnose_technical_seo(data)
assert any(item.status == DiagnosisStatus.FAIL for item in result.items if item.name == "索引状态")
def test_crawl_errors_warning(self):
"""测试少量爬取错误"""
data = TechnicalSEOData(crawl_errors=3)
result = diagnose_technical_seo(data)
crawl_item = next(item for item in result.items if item.name == "爬取错误")
assert crawl_item.status == DiagnosisStatus.WARNING
def test_crawl_errors_fail(self):
"""测试大量爬取错误"""
data = TechnicalSEOData(crawl_errors=10)
result = diagnose_technical_seo(data)
crawl_item = next(item for item in result.items if item.name == "爬取错误")
assert crawl_item.status == DiagnosisStatus.FAIL
def test_core_web_vitals_pass(self):
"""测试Core Web Vitals通过"""
data = TechnicalSEOData(
lcp_seconds=2.0,
fid_ms=80.0,
cls_score=0.05,
)
result = diagnose_technical_seo(data)
cwv_items = [item for item in result.items if item.name in ["LCP", "FID", "CLS"]]
assert all(item.status == DiagnosisStatus.PASS for item in cwv_items)
def test_core_web_vitals_warning(self):
"""测试Core Web Vitals警告"""
data = TechnicalSEOData(
lcp_seconds=3.0,
fid_ms=200.0,
cls_score=0.15,
)
result = diagnose_technical_seo(data)
cwv_items = [item for item in result.items if item.name in ["LCP", "FID", "CLS"]]
assert any(item.status == DiagnosisStatus.WARNING for item in cwv_items)
def test_core_web_vitals_fail(self):
"""测试Core Web Vitals失败"""
data = TechnicalSEOData(
lcp_seconds=5.0,
fid_ms=400.0,
cls_score=0.3,
)
result = diagnose_technical_seo(data)
cwv_items = [item for item in result.items if item.name in ["LCP", "FID", "CLS"]]
assert all(item.status == DiagnosisStatus.FAIL for item in cwv_items)
def test_robots_txt_blocks_important(self):
"""测试robots.txt阻止重要页面"""
data = TechnicalSEOData(
has_robots_txt=True,
robots_txt_blocks_important=True,
)
result = diagnose_technical_seo(data)
robots_item = next(item for item in result.items if item.name == "robots.txt")
assert robots_item.status == DiagnosisStatus.FAIL
def test_missing_sitemap(self):
"""测试缺少sitemap"""
data = TechnicalSEOData(has_sitemap=False)
result = diagnose_technical_seo(data)
sitemap_item = next(item for item in result.items if item.name == "sitemap")
assert sitemap_item.status == DiagnosisStatus.FAIL
class TestOnPageSEODiagnosis:
"""页面SEO诊断测试"""
def test_perfect_on_page_seo(self):
"""测试完美页面SEO"""
data = OnPageSEOData(
has_title=True,
title_length=50,
title_keyword_stuffing=False,
has_meta_description=True,
meta_description_length=140,
h1_count=1,
h_structure_valid=True,
keyword_density=2.0,
internal_links=10,
broken_internal_links=0,
images_without_alt=0,
total_images=5,
)
result = diagnose_on_page_seo(data)
assert result.score == result.max_score
assert result.status == DiagnosisStatus.PASS
def test_missing_title(self):
"""测试缺少Title"""
data = OnPageSEOData(has_title=False)
result = diagnose_on_page_seo(data)
title_item = next(item for item in result.items if item.name == "Title标签")
assert title_item.status == DiagnosisStatus.FAIL
def test_title_too_long(self):
"""测试Title过长"""
data = OnPageSEOData(title_length=80)
result = diagnose_on_page_seo(data)
title_item = next(item for item in result.items if item.name == "Title标签")
assert title_item.status == DiagnosisStatus.WARNING
def test_keyword_stuffing(self):
"""测试关键词堆砌"""
data = OnPageSEOData(title_keyword_stuffing=True)
result = diagnose_on_page_seo(data)
title_item = next(item for item in result.items if item.name == "Title标签")
assert title_item.status == DiagnosisStatus.WARNING
def test_multiple_h1(self):
"""测试多个H1"""
data = OnPageSEOData(h1_count=3)
result = diagnose_on_page_seo(data)
h_item = next(item for item in result.items if item.name == "H标签结构")
assert h_item.status == DiagnosisStatus.WARNING
def test_broken_links_warning(self):
"""测试少量死链"""
data = OnPageSEOData(broken_internal_links=2)
result = diagnose_on_page_seo(data)
link_item = next(item for item in result.items if item.name == "内链结构")
assert link_item.status == DiagnosisStatus.WARNING
def test_broken_links_fail(self):
"""测试大量死链"""
data = OnPageSEOData(broken_internal_links=10)
result = diagnose_on_page_seo(data)
link_item = next(item for item in result.items if item.name == "内链结构")
assert link_item.status == DiagnosisStatus.FAIL
def test_images_without_alt(self):
"""测试图片缺少Alt"""
data = OnPageSEOData(
images_without_alt=3,
total_images=5,
)
result = diagnose_on_page_seo(data)
alt_item = next(item for item in result.items if item.name == "图片Alt文本")
assert alt_item.status == DiagnosisStatus.FAIL
class TestContentQualityDiagnosis:
"""内容质量诊断测试"""
def test_perfect_content_quality(self):
"""测试完美内容质量"""
data = ContentQualityData(
readability_score=80.0,
word_count=2000,
topic_coverage=0.9,
has_author_info=True,
has_publication_date=True,
last_updated_days=10,
has_citations=True,
citation_authority=0.9,
duplicate_content_ratio=0.02,
has_expert_review=True,
)
result = diagnose_content_quality(data)
assert result.score == result.max_score
assert result.status == DiagnosisStatus.PASS
def test_low_readability(self):
"""测试低可读性"""
data = ContentQualityData(readability_score=40.0)
result = diagnose_content_quality(data)
readability_item = next(item for item in result.items if item.name == "可读性")
assert readability_item.status == DiagnosisStatus.FAIL
def test_shallow_content(self):
"""测试内容深度不足"""
data = ContentQualityData(
word_count=500,
topic_coverage=0.4,
)
result = diagnose_content_quality(data)
depth_item = next(item for item in result.items if item.name == "信息深度")
assert depth_item.status == DiagnosisStatus.FAIL
def test_missing_author(self):
"""测试缺少作者信息"""
data = ContentQualityData(has_author_info=False)
result = diagnose_content_quality(data)
author_item = next(item for item in result.items if item.name == "作者资质")
assert author_item.status == DiagnosisStatus.WARNING
def test_stale_content(self):
"""测试过时内容"""
data = ContentQualityData(last_updated_days=200)
result = diagnose_content_quality(data)
freshness_item = next(item for item in result.items if item.name == "内容新鲜度")
assert freshness_item.status == DiagnosisStatus.FAIL
def test_high_duplicate_ratio(self):
"""测试高重复内容比例"""
data = ContentQualityData(duplicate_content_ratio=0.5)
result = diagnose_content_quality(data)
duplicate_item = next(item for item in result.items if item.name == "重复内容")
assert duplicate_item.status == DiagnosisStatus.FAIL
class TestBacklinkDiagnosis:
"""外链分析诊断测试"""
def test_perfect_backlinks(self):
"""测试完美外链"""
data = BacklinkData(
total_backlinks=200,
referring_domains=50,
high_authority_links=20,
toxic_links=0,
nofollow_ratio=0.3,
anchor_text_diversity=0.9,
exact_match_anchor_ratio=0.1,
)
result = diagnose_backlinks(data)
assert result.score == result.max_score
assert result.status == DiagnosisStatus.PASS
def test_few_referring_domains(self):
"""测试引用域名少"""
data = BacklinkData(referring_domains=5)
result = diagnose_backlinks(data)
domain_item = next(item for item in result.items if item.name == "引用域名")
assert domain_item.status == DiagnosisStatus.FAIL
def test_toxic_links_warning(self):
"""测试少量毒性链接"""
data = BacklinkData(
total_backlinks=100,
toxic_links=3,
)
result = diagnose_backlinks(data)
toxic_item = next(item for item in result.items if item.name == "毒性链接")
assert toxic_item.status == DiagnosisStatus.WARNING
def test_toxic_links_fail(self):
"""测试大量毒性链接"""
data = BacklinkData(
total_backlinks=50,
toxic_links=10,
)
result = diagnose_backlinks(data)
toxic_item = next(item for item in result.items if item.name == "毒性链接")
assert toxic_item.status == DiagnosisStatus.FAIL
def test_low_anchor_diversity(self):
"""测试锚文本多样性低"""
data = BacklinkData(
anchor_text_diversity=0.3,
exact_match_anchor_ratio=0.6,
)
result = diagnose_backlinks(data)
anchor_item = next(item for item in result.items if item.name == "锚文本分布")
assert anchor_item.status == DiagnosisStatus.FAIL
class TestUserExperienceDiagnosis:
"""用户体验诊断测试"""
def test_perfect_ux(self):
"""测试完美用户体验"""
data = UserExperienceData(
is_mobile_friendly=True,
mobile_viewport_set=True,
page_load_time=1.5,
has_https=True,
has_breadcrumbs=True,
conversion_path_clear=True,
has_cta=True,
form_usability=0.95,
has_search=True,
)
result = diagnose_user_experience(data)
assert result.score == result.max_score
assert result.status == DiagnosisStatus.PASS
def test_not_mobile_friendly(self):
"""测试不移动友好"""
data = UserExperienceData(is_mobile_friendly=False)
result = diagnose_user_experience(data)
mobile_item = next(item for item in result.items if item.name == "移动适配")
assert mobile_item.status == DiagnosisStatus.FAIL
def test_slow_page_load(self):
"""测试页面加载慢"""
data = UserExperienceData(page_load_time=5.0)
result = diagnose_user_experience(data)
speed_item = next(item for item in result.items if item.name == "页面速度")
assert speed_item.status == DiagnosisStatus.FAIL
def test_missing_https(self):
"""测试缺少HTTPS"""
data = UserExperienceData(has_https=False)
result = diagnose_user_experience(data)
https_item = next(item for item in result.items if item.name == "HTTPS")
assert https_item.status == DiagnosisStatus.FAIL
def test_missing_cta(self):
"""测试缺少CTA"""
data = UserExperienceData(has_cta=False)
result = diagnose_user_experience(data)
cta_item = next(item for item in result.items if item.name == "CTA")
assert cta_item.status == DiagnosisStatus.WARNING
class TestRecommendations:
"""优化建议生成测试"""
def test_generate_recommendations(self):
"""测试建议生成"""
result = SEODiagnosisResult(
overall_score=60.0,
dimensions=[
SEODimensionScore(
name="测试维度",
score=10.0,
max_score=20.0,
items=[
DiagnosisItem(
name="失败项",
status=DiagnosisStatus.FAIL,
description="描述",
suggestion="修复建议",
),
DiagnosisItem(
name="警告项",
status=DiagnosisStatus.WARNING,
description="描述",
suggestion="优化建议",
),
DiagnosisItem(
name="通过项",
status=DiagnosisStatus.PASS,
description="描述",
suggestion="保持",
),
],
status=DiagnosisStatus.WARNING,
),
],
recommendations=[],
)
recommendations = generate_recommendations(result)
assert len(recommendations) == 2
assert recommendations[0].priority == "high"
assert recommendations[1].priority == "medium"
def test_recommendations_sorted_by_priority(self):
"""测试建议按优先级排序"""
result = SEODiagnosisResult(
overall_score=50.0,
dimensions=[
SEODimensionScore(
name="维度1",
score=5.0,
max_score=10.0,
items=[
DiagnosisItem(
name="警告项",
status=DiagnosisStatus.WARNING,
description="",
suggestion="",
),
],
status=DiagnosisStatus.WARNING,
),
SEODimensionScore(
name="维度2",
score=5.0,
max_score=10.0,
items=[
DiagnosisItem(
name="失败项",
status=DiagnosisStatus.FAIL,
description="",
suggestion="",
),
],
status=DiagnosisStatus.WARNING,
),
],
recommendations=[],
)
recommendations = generate_recommendations(result)
assert recommendations[0].priority == "high"
assert recommendations[1].priority == "medium"
class TestSEODiagnosisService:
"""SEO诊断服务测试"""
@pytest.fixture
def service(self):
"""创建诊断服务实例"""
return SEODiagnosisService()
def test_full_diagnosis_with_defaults(self, service):
"""测试使用默认数据的完整诊断"""
result = service.diagnose()
assert isinstance(result, SEODiagnosisResult)
assert 0 <= result.overall_score <= 100
assert len(result.dimensions) == 5
assert isinstance(result.recommendations, list)
def test_diagnosis_returns_all_dimensions(self, service):
"""测试诊断返回所有维度"""
result = service.diagnose()
dimension_names = [dim.name for dim in result.dimensions]
assert "技术SEO" in dimension_names
assert "页面SEO" in dimension_names
assert "内容质量" in dimension_names
assert "外链分析" in dimension_names
assert "用户体验" in dimension_names
def test_diagnosis_with_custom_data(self, service):
"""测试使用自定义数据的诊断"""
technical_data = TechnicalSEOData(
is_indexed=False,
crawl_errors=10,
)
result = service.diagnose(technical_data=technical_data)
assert result.overall_score < 100
def test_diagnose_technical_only(self, service):
"""测试仅技术SEO诊断"""
result = service.diagnose_technical_only()
assert isinstance(result, SEODimensionScore)
assert result.name == DimensionName.TECHNICAL_SEO
def test_diagnose_on_page_only(self, service):
"""测试仅页面SEO诊断"""
result = service.diagnose_on_page_only()
assert isinstance(result, SEODimensionScore)
assert result.name == DimensionName.ON_PAGE_SEO
def test_diagnose_content_only(self, service):
"""测试仅内容质量诊断"""
result = service.diagnose_content_only()
assert isinstance(result, SEODimensionScore)
assert result.name == DimensionName.CONTENT_QUALITY
def test_diagnose_backlinks_only(self, service):
"""测试仅外链分析"""
result = service.diagnose_backlinks_only()
assert isinstance(result, SEODimensionScore)
assert result.name == DimensionName.BACKLINK_ANALYSIS
def test_diagnose_ux_only(self, service):
"""测试仅用户体验诊断"""
result = service.diagnose_ux_only()
assert isinstance(result, SEODimensionScore)
assert result.name == DimensionName.USER_EXPERIENCE
def test_diagnosis_with_poor_data(self, service):
"""测试使用差数据的诊断"""
technical_data = TechnicalSEOData(
is_indexed=False,
crawl_errors=20,
lcp_seconds=6.0,
fid_ms=500.0,
cls_score=0.4,
has_robots_txt=False,
has_sitemap=False,
)
on_page_data = OnPageSEOData(
has_title=False,
has_meta_description=False,
h1_count=0,
broken_internal_links=10,
)
content_data = ContentQualityData(
readability_score=30.0,
word_count=200,
last_updated_days=365,
duplicate_content_ratio=0.6,
)
backlink_data = BacklinkData(
referring_domains=2,
toxic_links=20,
anchor_text_diversity=0.2,
)
ux_data = UserExperienceData(
is_mobile_friendly=False,
page_load_time=8.0,
has_https=False,
)
result = service.diagnose(
technical_data=technical_data,
on_page_data=on_page_data,
content_data=content_data,
backlink_data=backlink_data,
ux_data=ux_data,
)
assert result.overall_score < 30
assert result.health_level == "danger"
assert len(result.recommendations) > 0
assert any(rec.priority == "high" for rec in result.recommendations)
def test_diagnosis_with_excellent_data(self, service):
"""测试使用优秀数据的诊断"""
technical_data = TechnicalSEOData(
is_indexed=True,
crawl_errors=0,
lcp_seconds=1.5,
fid_ms=50.0,
cls_score=0.03,
)
on_page_data = OnPageSEOData(
title_length=45,
meta_description_length=140,
keyword_density=2.0,
)
content_data = ContentQualityData(
readability_score=85.0,
word_count=2500,
topic_coverage=0.95,
has_expert_review=True,
last_updated_days=5,
)
backlink_data = BacklinkData(
referring_domains=50,
high_authority_links=20,
toxic_links=0,
anchor_text_diversity=0.9,
)
ux_data = UserExperienceData(
page_load_time=1.2,
form_usability=0.95,
)
result = service.diagnose(
technical_data=technical_data,
on_page_data=on_page_data,
content_data=content_data,
backlink_data=backlink_data,
ux_data=ux_data,
)
assert result.overall_score >= 80
assert result.health_level == "excellent"
def test_result_to_dict_format(self, service):
"""测试结果字典格式"""
result = service.diagnose()
d = result.to_dict()
assert "overall_score" in d
assert "health_level" in d
assert "health_level_label" in d
assert "dimensions" in d
assert "recommendations" in d
assert isinstance(d["dimensions"], list)
assert isinstance(d["recommendations"], list)
if d["dimensions"]:
dim = d["dimensions"][0]
assert "name" in dim
assert "score" in dim
assert "max_score" in dim
assert "percentage" in dim
assert "status" in dim
assert "items" in dim