feat: 提交热榜评论分析工具 MVP 基线
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Test package."""
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base, get_db_session
|
||||
from app.main import app
|
||||
|
||||
|
||||
@contextmanager
|
||||
def make_test_client() -> Iterator[tuple[TestClient, object]]:
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
def override_db_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db_session] = override_db_session
|
||||
try:
|
||||
yield TestClient(app), engine
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests."""
|
||||
@@ -0,0 +1,51 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.platforms.base import PlatformAPIError
|
||||
from app.schemas import CreateTaskRequest
|
||||
from app.services.task_service import create_task, run_task
|
||||
from tests.helpers import make_test_client
|
||||
|
||||
|
||||
class FailingHotspotPlatform:
|
||||
def fetch_hotspots(self, *, limit):
|
||||
raise PlatformAPIError("hotspot failed", error_type="api_error", status_code=500)
|
||||
|
||||
|
||||
def test_hotspot_failure_marks_task_failed(monkeypatch):
|
||||
with make_test_client() as (_client, engine):
|
||||
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FailingHotspotPlatform())
|
||||
with Session(engine) as session:
|
||||
task = create_task(
|
||||
session,
|
||||
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
|
||||
submit_background=False,
|
||||
)
|
||||
task_id = task.id
|
||||
run_task(task_id, session_factory=lambda: session)
|
||||
task = session.get(type(task), task_id)
|
||||
|
||||
assert task.status == "failed"
|
||||
assert task.error_stage == "crawl_hotspots"
|
||||
assert task.error_type == "api_error"
|
||||
|
||||
|
||||
def test_unexpected_task_exception_marks_task_failed(monkeypatch):
|
||||
def raise_unexpected_error(_platform):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with make_test_client() as (_client, engine):
|
||||
monkeypatch.setattr("app.services.task_service.build_platform", raise_unexpected_error)
|
||||
with Session(engine) as session:
|
||||
task = create_task(
|
||||
session,
|
||||
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
|
||||
submit_background=False,
|
||||
)
|
||||
task_id = task.id
|
||||
run_task(task_id, session_factory=lambda: session)
|
||||
task = session.get(type(task), task_id)
|
||||
|
||||
assert task.status == "failed"
|
||||
assert task.error_stage == "system"
|
||||
assert task.error_type == "unexpected_error"
|
||||
assert task.error_message == "boom"
|
||||
@@ -0,0 +1,12 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health_returns_ok():
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
@@ -0,0 +1,146 @@
|
||||
from app.models import Comment, ContentItem, Hotspot, Report, Task
|
||||
from tests.helpers import make_test_client
|
||||
|
||||
|
||||
def test_index_page_renders_task_form_empty_state_and_default_scale():
|
||||
with make_test_client() as (client, _engine):
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "热榜评论分析工具" in response.text
|
||||
assert 'name="platform"' in response.text
|
||||
assert 'name="hotspot_limit"' in response.text
|
||||
assert 'name="item_limit_per_hotspot"' in response.text
|
||||
assert 'name="comment_limit_per_item"' in response.text
|
||||
assert "1250" in response.text
|
||||
assert 'onclick="window.location.href=\'/\'"' in response.text
|
||||
assert "还没有任何任务" in response.text
|
||||
|
||||
|
||||
def test_index_page_lists_existing_tasks():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="task-visible",
|
||||
platform="douyin",
|
||||
status="running",
|
||||
hotspot_limit=3,
|
||||
item_limit_per_hotspot=4,
|
||||
comment_limit_per_item=50,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "task-visible" in response.text
|
||||
assert "抖音" in response.text
|
||||
assert "运行中" in response.text
|
||||
|
||||
|
||||
def seed_result_data(engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
task = Task(id="task-result", platform="douyin", status="success", total_items_count=1, successful_items_count=1)
|
||||
session.add(task)
|
||||
hotspot = Hotspot(id="hot-result", task_id=task.id, platform="douyin", title="热点标题", rank=1, raw_data="{}")
|
||||
session.add(hotspot)
|
||||
item = ContentItem(
|
||||
id="item-result",
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
platform="douyin",
|
||||
source_item_id="v1",
|
||||
item_type="video",
|
||||
title="视频标题",
|
||||
status="success",
|
||||
raw_data="{}",
|
||||
)
|
||||
session.add(item)
|
||||
session.add(
|
||||
Comment(
|
||||
id="comment-result",
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
content_item_id=item.id,
|
||||
platform="douyin",
|
||||
source_comment_id="c1",
|
||||
content="评论内容",
|
||||
sentiment="positive",
|
||||
labels='["认可"]',
|
||||
like_count=3,
|
||||
raw_data="{}",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Report(
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
report_type="hotspot",
|
||||
title="热点标题",
|
||||
metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100}},"top_labels":[{"name":"认可","count":1}]}',
|
||||
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}]}',
|
||||
summary="热点总结",
|
||||
markdown_content="# 热点标题\n\n热点总结",
|
||||
data="{}",
|
||||
markdown="# 热点标题\n\n热点总结",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Report(
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
content_item_id=item.id,
|
||||
report_type="item",
|
||||
title="视频标题",
|
||||
metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100}},"top_labels":[{"name":"认可","count":1}]}',
|
||||
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}]}',
|
||||
summary="内容总结",
|
||||
markdown_content="# 视频标题\n\n内容总结",
|
||||
data="{}",
|
||||
markdown="# 视频标题\n\n内容总结",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_result_pages_render_seeded_data():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
|
||||
task_response = client.get("/tasks/task-result")
|
||||
hotspot_response = client.get("/hotspots/hot-result/report")
|
||||
item_response = client.get("/items/item-result")
|
||||
|
||||
assert task_response.status_code == 200
|
||||
assert "热点标题" in task_response.text
|
||||
assert 'onclick="window.location.href=\'/\'"' in task_response.text
|
||||
assert hotspot_response.status_code == 200
|
||||
assert "热点总结" in hotspot_response.text
|
||||
assert item_response.status_code == 200
|
||||
assert "评论内容" in item_response.text
|
||||
|
||||
|
||||
def test_export_routes_return_csv_and_markdown():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
|
||||
csv_response = client.get("/api/export/items/item-result/comments.csv")
|
||||
hotspot_csv_response = client.get("/api/export/hotspots/hot-result/comments.csv")
|
||||
hotspot_md = client.get("/api/export/hotspots/hot-result.md")
|
||||
item_md = client.get("/api/export/items/item-result.md")
|
||||
|
||||
assert csv_response.status_code == 200
|
||||
assert csv_response.content.startswith(b"\xef\xbb\xbf")
|
||||
assert "评论内容".encode("utf-8") in csv_response.content
|
||||
assert hotspot_csv_response.status_code == 200
|
||||
assert "评论内容".encode("utf-8") in hotspot_csv_response.content
|
||||
assert hotspot_md.status_code == 200
|
||||
assert "热点总结" in hotspot_md.text
|
||||
assert item_md.status_code == 200
|
||||
assert "内容总结" in item_md.text
|
||||
@@ -0,0 +1,118 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base, get_db_session
|
||||
from app.main import app
|
||||
from app.models import Task
|
||||
|
||||
|
||||
def make_test_client():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
def override_db_session():
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db_session] = override_db_session
|
||||
return TestClient(app), engine
|
||||
|
||||
|
||||
def test_create_task_returns_task_id_and_running_status():
|
||||
client, engine = make_test_client()
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/tasks",
|
||||
json={
|
||||
"platform": "xiaohongshu",
|
||||
"hotspot_limit": 5,
|
||||
"item_limit_per_hotspot": 5,
|
||||
"comment_limit_per_item": 50,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["task_id"]
|
||||
assert data["status"] == "running"
|
||||
finally:
|
||||
engine.dispose()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_create_task_rejects_invalid_platform_and_limits():
|
||||
client, engine = make_test_client()
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/tasks",
|
||||
json={
|
||||
"platform": "weibo",
|
||||
"hotspot_limit": 11,
|
||||
"item_limit_per_hotspot": 0,
|
||||
"comment_limit_per_item": 101,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
finally:
|
||||
engine.dispose()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_create_task_returns_400_when_running_task_exists():
|
||||
client, engine = make_test_client()
|
||||
try:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(Task(platform="douyin", status="running"))
|
||||
session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/tasks",
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"hotspot_limit": 5,
|
||||
"item_limit_per_hotspot": 5,
|
||||
"comment_limit_per_item": 50,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "当前有正在运行的任务,请稍后再试"}
|
||||
finally:
|
||||
engine.dispose()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_task_list_and_detail_return_created_tasks():
|
||||
client, engine = make_test_client()
|
||||
try:
|
||||
created = client.post(
|
||||
"/api/tasks",
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"hotspot_limit": 3,
|
||||
"item_limit_per_hotspot": 4,
|
||||
"comment_limit_per_item": 30,
|
||||
},
|
||||
).json()
|
||||
|
||||
list_response = client.get("/api/tasks")
|
||||
detail_response = client.get(f"/api/tasks/{created['task_id']}")
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()[0]["task_id"] == created["task_id"]
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.json()["platform"] == "douyin"
|
||||
assert detail_response.json()["hotspot_limit"] == 3
|
||||
finally:
|
||||
engine.dispose()
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Comment, Task
|
||||
from app.platforms.base import CommentData, ContentItemData, HotspotData
|
||||
from app.schemas import CreateTaskRequest
|
||||
from app.services.task_service import create_task, run_task
|
||||
from tests.helpers import make_test_client
|
||||
|
||||
|
||||
class FakeDouyinPlatform:
|
||||
def fetch_hotspots(self, *, limit):
|
||||
return [HotspotData(source_hot_id="d1", title="抖音热点", rank=1, heat_value="200", raw_data={})]
|
||||
|
||||
def search_items_by_hotspot(self, keyword, *, limit):
|
||||
return [ContentItemData(source_item_id="v1", item_type="video", title="视频", raw_data={})]
|
||||
|
||||
def fetch_comments(self, source_item_id, *, limit):
|
||||
return [CommentData(source_comment_id="dc1", content="抖音评论", raw_data={})]
|
||||
|
||||
|
||||
def test_douyin_task_flow_persists_comments(monkeypatch):
|
||||
with make_test_client() as (_client, engine):
|
||||
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeDouyinPlatform())
|
||||
|
||||
with Session(engine) as session:
|
||||
task = create_task(
|
||||
session,
|
||||
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
|
||||
submit_background=False,
|
||||
)
|
||||
task_id = task.id
|
||||
|
||||
with Session(engine) as session:
|
||||
run_task(task_id, session_factory=lambda: session)
|
||||
task = session.get(Task, task_id)
|
||||
comment = session.query(Comment).filter_by(task_id=task_id).one()
|
||||
|
||||
assert task.status == "success"
|
||||
assert comment.content == "抖音评论"
|
||||
@@ -0,0 +1,130 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Comment, ContentItem, Hotspot, Report, Task
|
||||
from app.schemas import CreateTaskRequest
|
||||
from app.services.task_service import create_task, run_task
|
||||
from tests.helpers import make_test_client
|
||||
|
||||
|
||||
class FakeXhsPlatform:
|
||||
def fetch_hotspots(self, *, limit):
|
||||
from app.platforms.base import HotspotData
|
||||
|
||||
return [HotspotData(source_hot_id="h1", title="热点一", rank=1, heat_value="100", raw_data={"id": "h1"})]
|
||||
|
||||
def search_items_by_hotspot(self, keyword, *, limit):
|
||||
from app.platforms.base import ContentItemData
|
||||
|
||||
return [
|
||||
ContentItemData(source_item_id="n1", item_type="note", title=f"{keyword} 笔记", raw_data={"id": "n1"})
|
||||
]
|
||||
|
||||
def fetch_comments(self, source_item_id, *, limit):
|
||||
from app.platforms.base import CommentData
|
||||
|
||||
return [CommentData(source_comment_id="c1", content="评论一", like_count=5, raw_data={"id": "c1"})]
|
||||
|
||||
|
||||
def test_xiaohongshu_task_flow_persists_hotspots_items_and_comments(monkeypatch):
|
||||
with make_test_client() as (_client, engine):
|
||||
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
|
||||
monkeypatch.setattr(
|
||||
"app.services.task_service.build_ai_requester",
|
||||
lambda: (
|
||||
lambda prompt: (
|
||||
'[{"comment_id":"'
|
||||
+ ("c1" if "c1" in prompt else prompt.split('"comment_id": "')[1].split('"')[0])
|
||||
+ '","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
with Session(engine) as session:
|
||||
task = create_task(
|
||||
session,
|
||||
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
|
||||
submit_background=False,
|
||||
)
|
||||
task_id = task.id
|
||||
|
||||
with Session(engine) as session:
|
||||
run_task(task_id, session_factory=lambda: session)
|
||||
|
||||
persisted = session.get(Task, task_id)
|
||||
assert persisted.status == "success"
|
||||
assert persisted.processed_items_count == 1
|
||||
assert persisted.successful_items_count == 1
|
||||
assert persisted.analysis_status == "normal"
|
||||
assert session.scalar(select(Hotspot).where(Hotspot.task_id == task_id)).title == "热点一"
|
||||
assert session.scalar(select(ContentItem).where(ContentItem.task_id == task_id)).source_item_id == "n1"
|
||||
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
|
||||
assert comment.content == "评论一"
|
||||
assert comment.ai_analysis_status == "success"
|
||||
assert comment.sentiment == "positive"
|
||||
assert comment.labels == '["认可"]'
|
||||
assert comment.reason == "喜欢"
|
||||
assert session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "item")) is not None
|
||||
assert session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "hotspot")) is not None
|
||||
|
||||
|
||||
def test_xiaohongshu_task_flow_marks_comments_failed_when_ai_parse_fails(monkeypatch):
|
||||
with make_test_client() as (_client, engine):
|
||||
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
|
||||
monkeypatch.setattr("app.services.task_service.build_ai_requester", lambda: (lambda _prompt: "not-json"))
|
||||
monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
|
||||
|
||||
with Session(engine) as session:
|
||||
task = create_task(
|
||||
session,
|
||||
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
|
||||
submit_background=False,
|
||||
)
|
||||
task_id = task.id
|
||||
|
||||
with Session(engine) as session:
|
||||
run_task(task_id, session_factory=lambda: session)
|
||||
|
||||
persisted = session.get(Task, task_id)
|
||||
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
|
||||
|
||||
assert persisted.status == "success"
|
||||
assert persisted.analysis_status == "insufficient"
|
||||
assert persisted.analysis_success_rate == 0.0
|
||||
assert comment.ai_analysis_status == "failed"
|
||||
assert comment.sentiment == "unknown"
|
||||
assert comment.labels == "[]"
|
||||
assert comment.reason == "ai_parse_failed"
|
||||
|
||||
|
||||
def test_xiaohongshu_task_flow_keeps_item_success_when_ai_request_raises(monkeypatch):
|
||||
with make_test_client() as (_client, engine):
|
||||
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
|
||||
monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
|
||||
|
||||
def failing_requester(_prompt):
|
||||
raise RuntimeError("ai unauthorized")
|
||||
|
||||
monkeypatch.setattr("app.services.task_service.build_ai_requester", lambda: failing_requester)
|
||||
|
||||
with Session(engine) as session:
|
||||
task = create_task(
|
||||
session,
|
||||
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
|
||||
submit_background=False,
|
||||
)
|
||||
task_id = task.id
|
||||
|
||||
with Session(engine) as session:
|
||||
run_task(task_id, session_factory=lambda: session)
|
||||
|
||||
persisted = session.get(Task, task_id)
|
||||
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
|
||||
|
||||
assert persisted.status == "success"
|
||||
assert persisted.successful_items_count == 1
|
||||
assert persisted.failed_items_count == 0
|
||||
assert persisted.analysis_status == "insufficient"
|
||||
assert persisted.analysis_success_rate == 0.0
|
||||
assert comment.ai_analysis_status == "failed"
|
||||
assert comment.reason == "ai_parse_failed"
|
||||
@@ -0,0 +1,40 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base
|
||||
from app.main import app
|
||||
from app.models import Task
|
||||
|
||||
|
||||
def test_lifespan_marks_running_tasks_failed_after_restart(monkeypatch):
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with TestingSessionLocal() as session:
|
||||
session.add(Task(platform="xiaohongshu", status="running"))
|
||||
session.commit()
|
||||
|
||||
monkeypatch.setattr("app.main.SessionLocal", TestingSessionLocal, raising=False)
|
||||
monkeypatch.setattr("app.main.init_db", lambda: None)
|
||||
|
||||
try:
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
with Session(engine) as session:
|
||||
task = session.scalar(select(Task))
|
||||
|
||||
assert task is not None
|
||||
assert task.status == "failed"
|
||||
assert task.error_stage == "system"
|
||||
assert task.error_type == "unexpected_restart"
|
||||
assert task.error_message == "系统重启,任务被中断"
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests."""
|
||||
@@ -0,0 +1,119 @@
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from app.services.ai_service import (
|
||||
AIAnalysisResult,
|
||||
OpenAICompatibleAIClient,
|
||||
analyze_comments_with_retry,
|
||||
build_comment_prompt,
|
||||
calculate_analysis_status,
|
||||
parse_ai_comment_response,
|
||||
)
|
||||
|
||||
|
||||
def test_build_comment_prompt_contains_ids_and_truncates_content():
|
||||
prompt = build_comment_prompt([{"comment_id": "c1", "content": "你" * 200}])
|
||||
|
||||
assert "c1" in prompt
|
||||
assert "请原样回填输入中的 comment_id" in prompt
|
||||
assert "你" * 150 in prompt
|
||||
assert "你" * 151 not in prompt
|
||||
|
||||
|
||||
def test_parse_ai_comment_response_validates_array_sentiment_labels_and_ids():
|
||||
result = parse_ai_comment_response(
|
||||
'[{"comment_id":"c1","sentiment":"positive","labels":["质量好"],"reason":"认可"}]',
|
||||
expected_comment_ids={"c1"},
|
||||
)
|
||||
|
||||
assert result == [AIAnalysisResult(comment_id="c1", sentiment="positive", labels=["质量好"], reason="认可")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
"not-json",
|
||||
'{"comment_id":"c1"}',
|
||||
'[{"comment_id":"missing","sentiment":"positive","labels":[],"reason":""}]',
|
||||
'[{"comment_id":"c1","sentiment":"happy","labels":[],"reason":""}]',
|
||||
'[{"comment_id":"c1","sentiment":"positive","labels":["a","b","c","d"],"reason":""}]',
|
||||
],
|
||||
)
|
||||
def test_parse_ai_comment_response_rejects_invalid_payloads(payload):
|
||||
with pytest.raises(ValueError):
|
||||
parse_ai_comment_response(payload, expected_comment_ids={"c1"})
|
||||
|
||||
|
||||
def test_calculate_analysis_status_uses_eighty_percent_threshold():
|
||||
assert calculate_analysis_status(success_count=8, total_count=10) == (0.8, "normal")
|
||||
assert calculate_analysis_status(success_count=7, total_count=10) == (0.7, "insufficient")
|
||||
assert calculate_analysis_status(success_count=0, total_count=0) == (0.0, "insufficient")
|
||||
|
||||
|
||||
def test_analyze_comments_with_retry_marks_batch_failed_after_three_parse_failures(monkeypatch):
|
||||
sleeps = []
|
||||
monkeypatch.setattr("app.services.ai_service.time.sleep", sleeps.append)
|
||||
|
||||
results = analyze_comments_with_retry(
|
||||
[{"comment_id": "c1", "content": "内容"}],
|
||||
requester=lambda _prompt: "not-json",
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
assert results[0].comment_id == "c1"
|
||||
assert results[0].sentiment == "unknown"
|
||||
assert results[0].ai_analysis_status == "failed"
|
||||
assert results[0].reason == "ai_parse_failed"
|
||||
assert sleeps == [1, 2]
|
||||
|
||||
|
||||
def test_openai_compatible_client_posts_chat_completion_and_returns_message_content():
|
||||
captured = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["url"] = str(request.url)
|
||||
captured["authorization"] = request.headers.get("authorization")
|
||||
captured["body"] = request.read().decode("utf-8")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"choices": [{"message": {"content": '[{"comment_id":"c1","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'}}]},
|
||||
)
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
client = OpenAICompatibleAIClient(
|
||||
base_url="https://ai.example.com",
|
||||
api_key="test-ai-key",
|
||||
model="test-model",
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
result = client.request("prompt text")
|
||||
|
||||
assert result == '[{"comment_id":"c1","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'
|
||||
assert captured["url"] == "https://ai.example.com/v1/chat/completions"
|
||||
assert captured["authorization"] == "Bearer test-ai-key"
|
||||
assert '"model":"test-model"' in captured["body"]
|
||||
assert "prompt text" in captured["body"]
|
||||
|
||||
|
||||
def test_openai_compatible_client_accepts_base_url_that_already_includes_v1():
|
||||
captured = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["url"] = str(request.url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"choices": [{"message": {"content": "[]"}}]},
|
||||
)
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
client = OpenAICompatibleAIClient(
|
||||
base_url="https://ai.example.com/v1",
|
||||
api_key="test-ai-key",
|
||||
model="test-model",
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
client.request("prompt text")
|
||||
|
||||
assert captured["url"] == "https://ai.example.com/v1/chat/completions"
|
||||
@@ -0,0 +1,51 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.platforms.base import PlatformAPIError, TikHubClient
|
||||
|
||||
|
||||
class SequenceTransport:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.requests = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
response = self.responses.pop(0)
|
||||
response.request = request
|
||||
return response
|
||||
|
||||
|
||||
def test_tikhub_client_retries_429_with_exponential_backoff(monkeypatch):
|
||||
sleeps = []
|
||||
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
|
||||
transport = SequenceTransport(
|
||||
[
|
||||
httpx.Response(429, json={"message": "Too Many Requests"}),
|
||||
httpx.Response(429, json={"message": "Too Many Requests"}),
|
||||
httpx.Response(200, json={"ok": True}),
|
||||
]
|
||||
)
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(transport))
|
||||
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
|
||||
|
||||
result = client.get("/demo")
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert sleeps == [1, 2]
|
||||
assert len(transport.requests) == 3
|
||||
assert transport.requests[0].headers["Authorization"] == "Bearer secret-token"
|
||||
|
||||
|
||||
def test_tikhub_client_raises_structured_error_after_retries(monkeypatch):
|
||||
monkeypatch.setattr("app.platforms.base.time.sleep", lambda _seconds: None)
|
||||
transport = SequenceTransport([httpx.Response(429, json={"message": "Too Many Requests"}) for _ in range(4)])
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(transport))
|
||||
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
|
||||
|
||||
with pytest.raises(PlatformAPIError) as exc_info:
|
||||
client.get("/demo")
|
||||
|
||||
assert exc_info.value.error_type == "rate_limited"
|
||||
assert exc_info.value.status_code == 429
|
||||
assert "secret-token" not in str(exc_info.value)
|
||||
@@ -0,0 +1,76 @@
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_settings_use_t01_defaults(monkeypatch):
|
||||
monkeypatch.delenv("APP_ENV", raising=False)
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
monkeypatch.delenv("TIKHUB_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("AI_PROVIDER", raising=False)
|
||||
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.app_env == "development"
|
||||
assert settings.database_url == "sqlite:///data/app.db"
|
||||
assert settings.tikhub_base_url == "https://api.tikhub.io"
|
||||
assert settings.ai_provider == "openai-compatible"
|
||||
|
||||
|
||||
def test_settings_read_environment_overrides(monkeypatch):
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
monkeypatch.setenv("TIKHUB_API_KEY", "test-token")
|
||||
monkeypatch.setenv("AI_API_KEY", "test-ai-key")
|
||||
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.app_env == "test"
|
||||
assert settings.database_url == "sqlite:///:memory:"
|
||||
assert settings.tikhub_api_key == "test-token"
|
||||
assert settings.ai_api_key == "test-ai-key"
|
||||
|
||||
|
||||
def test_settings_include_t02_defaults(monkeypatch):
|
||||
monkeypatch.delenv("AI_BATCH_SIZE", raising=False)
|
||||
monkeypatch.delenv("AI_CONCURRENCY", raising=False)
|
||||
monkeypatch.delenv("AI_MAX_RETRIES", raising=False)
|
||||
monkeypatch.delenv("AI_TIMEOUT_SECONDS", raising=False)
|
||||
monkeypatch.delenv("HTTP_TIMEOUT_SECONDS", raising=False)
|
||||
monkeypatch.delenv("HTTP_MAX_RETRIES", raising=False)
|
||||
monkeypatch.delenv("CRAWL_PAGE_INTERVAL_SECONDS", raising=False)
|
||||
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.ai_batch_size == 20
|
||||
assert settings.ai_concurrency == 2
|
||||
assert settings.ai_max_retries == 3
|
||||
assert settings.ai_timeout_seconds == 30
|
||||
assert settings.http_timeout_seconds == 20
|
||||
assert settings.http_max_retries == 3
|
||||
assert settings.crawl_page_interval_seconds == 1.5
|
||||
|
||||
|
||||
def test_settings_read_crawl_page_interval_from_environment(monkeypatch):
|
||||
monkeypatch.setenv("CRAWL_PAGE_INTERVAL_SECONDS", "0.25")
|
||||
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.crawl_page_interval_seconds == 0.25
|
||||
|
||||
|
||||
def test_ai_concurrency_has_hard_upper_bound(monkeypatch):
|
||||
monkeypatch.setenv("AI_CONCURRENCY", "4")
|
||||
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.ai_concurrency == 3
|
||||
|
||||
|
||||
def test_task_limit_ranges_are_available_on_settings():
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.hot_limit_min == 1
|
||||
assert settings.hot_limit_max == 10
|
||||
assert settings.item_limit_per_hot_min == 1
|
||||
assert settings.item_limit_per_hot_max == 10
|
||||
assert settings.comment_limit_per_item_min == 10
|
||||
assert settings.comment_limit_per_item_max == 100
|
||||
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_docker_compose_reads_real_env_file_not_example():
|
||||
compose_content = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert "- .env\n" in compose_content
|
||||
assert "- .env.example" not in compose_content
|
||||
|
||||
|
||||
def test_real_env_file_is_gitignored():
|
||||
gitignore_lines = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
|
||||
|
||||
assert ".env" in gitignore_lines
|
||||
@@ -0,0 +1,120 @@
|
||||
from app.platforms.douyin import DouyinPlatform
|
||||
|
||||
|
||||
def test_douyin_maps_hotspots():
|
||||
platform = DouyinPlatform(client=None)
|
||||
payload = {
|
||||
"data": {
|
||||
"word_list": [
|
||||
{"query_id": "q1", "title": "热点", "rank": 2, "hot_score": "888"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
hotspots = platform.map_hotspots(payload, limit=5)
|
||||
|
||||
assert hotspots[0].source_hot_id == "q1"
|
||||
assert hotspots[0].title == "热点"
|
||||
assert hotspots[0].rank == 2
|
||||
assert hotspots[0].heat_value == "888"
|
||||
|
||||
|
||||
def test_douyin_maps_hotspots_from_real_item_list_shape():
|
||||
platform = DouyinPlatform(client=None)
|
||||
payload = {
|
||||
"data": {
|
||||
"item_list": [
|
||||
{"query_id": "q-real", "sentence": "真实热点", "rank": 1, "hot_score": 999}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
hotspots = platform.map_hotspots(payload, limit=5)
|
||||
|
||||
assert hotspots[0].source_hot_id == "q-real"
|
||||
assert hotspots[0].title == "真实热点"
|
||||
assert hotspots[0].rank == 1
|
||||
assert hotspots[0].heat_value == "999"
|
||||
|
||||
|
||||
def test_douyin_maps_videos():
|
||||
platform = DouyinPlatform(client=None)
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"aweme_info": {
|
||||
"aweme_id": "aweme-1",
|
||||
"desc": "视频描述",
|
||||
"author": {"nickname": "作者"},
|
||||
"statistics": {"comment_count": 10},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
items = platform.map_items(payload, limit=5)
|
||||
|
||||
assert items[0].source_item_id == "aweme-1"
|
||||
assert items[0].title == "视频描述"
|
||||
assert items[0].item_type == "video"
|
||||
|
||||
|
||||
def test_douyin_maps_videos_from_real_business_data_shape():
|
||||
platform = DouyinPlatform(client=None)
|
||||
payload = {
|
||||
"data": {
|
||||
"business_data": [
|
||||
{
|
||||
"data": {
|
||||
"aweme_info": {
|
||||
"aweme_id": "aweme-real",
|
||||
"desc": "真实视频描述",
|
||||
"share_url": "https://example.com/video",
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
items = platform.map_items(payload, limit=5)
|
||||
|
||||
assert items[0].source_item_id == "aweme-real"
|
||||
assert items[0].title == "真实视频描述"
|
||||
assert items[0].url == "https://example.com/video"
|
||||
|
||||
|
||||
def test_douyin_maps_comment_id_and_missing_optional_fields():
|
||||
platform = DouyinPlatform(client=None)
|
||||
payload = {
|
||||
"comments": [
|
||||
{"comment_id": "preferred", "cid": "fallback", "text": "评论"},
|
||||
{"cid": "fallback-only", "text": "评论2", "digg_count": 9},
|
||||
]
|
||||
}
|
||||
|
||||
comments = platform.map_comments(payload, limit=10)
|
||||
|
||||
assert comments[0].source_comment_id == "preferred"
|
||||
assert comments[0].content == "评论"
|
||||
assert comments[0].like_count is None
|
||||
assert comments[0].comment_time is None
|
||||
assert comments[1].source_comment_id == "fallback-only"
|
||||
assert comments[1].like_count == 9
|
||||
|
||||
|
||||
def test_douyin_maps_comments_when_user_is_none():
|
||||
platform = DouyinPlatform(client=None)
|
||||
payload = {"data": {"comments": [{"cid": "c-none-user", "text": "评论", "user": None}]}}
|
||||
|
||||
comments = platform.map_comments(payload, limit=10)
|
||||
|
||||
assert comments[0].source_comment_id == "c-none-user"
|
||||
assert comments[0].author is None
|
||||
|
||||
|
||||
def test_douyin_maps_null_comments_as_empty_list():
|
||||
platform = DouyinPlatform(client=None)
|
||||
payload = {"data": {"comments": None}}
|
||||
|
||||
assert platform.map_comments(payload, limit=10) == []
|
||||
@@ -0,0 +1,19 @@
|
||||
from app.services.export_service import labels_to_text, safe_csv_field, safe_filename
|
||||
|
||||
|
||||
def test_labels_to_text_joins_json_array_with_chinese_comma():
|
||||
assert labels_to_text('["认可", "质量好"]') == "认可,质量好"
|
||||
assert labels_to_text("bad-json") == ""
|
||||
|
||||
|
||||
def test_safe_csv_field_prevents_formula_injection_and_newlines():
|
||||
assert safe_csv_field("=1+1") == "'=1+1"
|
||||
assert safe_csv_field("hello\nworld") == "hello world"
|
||||
|
||||
|
||||
def test_safe_filename_replaces_illegal_chars_and_truncates_keyword():
|
||||
filename = safe_filename("douyin", "task", "a/b:c*d?e<f>g|很长很长很长很长很长")
|
||||
|
||||
assert "/" not in filename
|
||||
assert "__" not in filename
|
||||
assert filename.endswith(".csv")
|
||||
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base, create_sqlite_engine
|
||||
from app.models import Comment, ContentItem, Hotspot, Report, Task, create_report_record
|
||||
|
||||
|
||||
def test_all_t03_tables_can_be_created_in_memory_sqlite():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
try:
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
assert set(Base.metadata.tables) >= {
|
||||
"tasks",
|
||||
"hotspots",
|
||||
"content_items",
|
||||
"comments",
|
||||
"reports",
|
||||
}
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_sqlite_engine_uses_required_connection_options():
|
||||
engine = create_sqlite_engine("sqlite:///data/app.db")
|
||||
try:
|
||||
assert engine.url.database == "data/app.db"
|
||||
assert engine.dialect.connect_args["check_same_thread"] is False
|
||||
assert engine.dialect.connect_args["timeout"] == 10
|
||||
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_task_status_rejects_partial_status_values():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
try:
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(Task(platform="douyin", status="partial_success"))
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_analysis_status_insufficient_does_not_change_task_status():
|
||||
task = Task(
|
||||
platform="xiaohongshu",
|
||||
status="success",
|
||||
analysis_status="insufficient",
|
||||
analysis_success_rate=0.5,
|
||||
)
|
||||
|
||||
assert task.status == "success"
|
||||
assert task.analysis_status == "insufficient"
|
||||
|
||||
|
||||
def test_models_define_relationships_and_indexes():
|
||||
assert any(index.name == "ix_comments_task_content_item" for index in Comment.__table__.indexes)
|
||||
assert any(index.name == "ix_content_items_task_hotspot" for index in ContentItem.__table__.indexes)
|
||||
assert any(index.name == "ix_reports_task_report_type" for index in Report.__table__.indexes)
|
||||
|
||||
assert Hotspot.task.property.mapper.class_ is Task
|
||||
assert ContentItem.hotspot.property.mapper.class_ is Hotspot
|
||||
assert Comment.content_item.property.mapper.class_ is ContentItem
|
||||
assert Report.task.property.mapper.class_ is Task
|
||||
|
||||
|
||||
def test_report_record_application_constraint_requires_matching_owner_id():
|
||||
with pytest.raises(ValueError, match="hotspot_id"):
|
||||
create_report_record(task_id="task-1", report_type="hotspot", title="热点报告")
|
||||
|
||||
with pytest.raises(ValueError, match="content_item_id"):
|
||||
create_report_record(task_id="task-1", report_type="content_item", title="内容报告")
|
||||
|
||||
report = create_report_record(
|
||||
task_id="task-1",
|
||||
report_type="hotspot",
|
||||
title="热点报告",
|
||||
hotspot_id="hot-1",
|
||||
)
|
||||
|
||||
assert report.hotspot_id == "hot-1"
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base
|
||||
from app.models import Comment, ContentItem, Hotspot, Task
|
||||
from app.services.report_service import build_comment_metrics, generate_item_report
|
||||
|
||||
|
||||
def test_build_comment_metrics_counts_sentiments_and_top_labels():
|
||||
comments = [
|
||||
Comment(content="好", sentiment="positive", labels='["质量好", "价格好"]', like_count=5),
|
||||
Comment(content="差", sentiment="negative", labels='["质量好"]', like_count=2),
|
||||
Comment(content="一般", sentiment="neutral", labels="[]", like_count=1),
|
||||
Comment(content="未知", sentiment="unknown", labels="[]", like_count=0),
|
||||
]
|
||||
|
||||
metrics = build_comment_metrics(comments)
|
||||
|
||||
assert metrics["sample_count"] == 4
|
||||
assert metrics["sentiment"]["positive"]["count"] == 1
|
||||
assert metrics["sentiment"]["negative"]["count"] == 1
|
||||
assert metrics["sentiment"]["neutral"]["count"] == 1
|
||||
assert metrics["sentiment"]["unknown"]["count"] == 1
|
||||
assert metrics["top_labels"][0] == {"name": "质量好", "count": 2}
|
||||
|
||||
|
||||
def test_generate_item_report_persists_markdown_and_default_summary():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
task = Task(platform="douyin", status="success")
|
||||
session.add(task)
|
||||
session.flush()
|
||||
hotspot = Hotspot(task_id=task.id, platform="douyin", title="热点", raw_data="{}")
|
||||
session.add(hotspot)
|
||||
session.flush()
|
||||
item = ContentItem(
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
platform="douyin",
|
||||
source_item_id="v1",
|
||||
item_type="video",
|
||||
title="视频",
|
||||
status="success",
|
||||
raw_data="{}",
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
session.add(
|
||||
Comment(
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
content_item_id=item.id,
|
||||
platform="douyin",
|
||||
source_comment_id="c1",
|
||||
content="好评",
|
||||
sentiment="positive",
|
||||
labels='["认可"]',
|
||||
like_count=10,
|
||||
comment_time=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_data="{}",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
report = generate_item_report(session, item.id, summary_provider=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("ai failed")))
|
||||
|
||||
assert report.report_type == "item"
|
||||
assert report.content_item_id == item.id
|
||||
assert "总结生成失败,请查看上方统计数据。" in report.markdown_content
|
||||
assert "样本评论数量" in report.markdown_content
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,12 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.services import task_service
|
||||
|
||||
|
||||
def test_task_executor_is_single_worker_thread_pool():
|
||||
assert isinstance(task_service.task_executor, ThreadPoolExecutor)
|
||||
assert task_service.task_executor._max_workers == 1
|
||||
|
||||
|
||||
def test_run_task_entrypoint_is_synchronous_function():
|
||||
assert task_service.inspect.iscoroutinefunction(task_service.run_task) is False
|
||||
@@ -0,0 +1,13 @@
|
||||
from app.templating import from_json_filter, status_badge_config
|
||||
|
||||
|
||||
def test_status_badge_config_centralizes_task_status_copy():
|
||||
assert status_badge_config("running") == ("运行中", "bg-warning text-dark")
|
||||
assert status_badge_config("success") == ("已完成", "bg-success")
|
||||
assert status_badge_config("failed") == ("失败", "bg-danger")
|
||||
assert status_badge_config("unknown") == ("未知", "bg-secondary")
|
||||
|
||||
|
||||
def test_from_json_filter_returns_empty_list_for_invalid_json():
|
||||
assert from_json_filter('["认可"]') == ["认可"]
|
||||
assert from_json_filter("bad-json") == []
|
||||
@@ -0,0 +1,65 @@
|
||||
from app.platforms.xiaohongshu import XiaohongshuPlatform
|
||||
|
||||
|
||||
def test_xiaohongshu_maps_hotspots_from_items_not_outer_title():
|
||||
platform = XiaohongshuPlatform(client=None)
|
||||
payload = {
|
||||
"data": {
|
||||
"data": {
|
||||
"title": "搜索发现",
|
||||
"items": [
|
||||
{"id": "hot-1", "title": "真实热点", "score": "999", "rank_change": 1}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hotspots = platform.map_hotspots(payload, limit=5)
|
||||
|
||||
assert hotspots[0].source_hot_id == "hot-1"
|
||||
assert hotspots[0].title == "真实热点"
|
||||
assert hotspots[0].heat_value == "999"
|
||||
|
||||
|
||||
def test_xiaohongshu_prefers_notes_with_comments_and_falls_back_to_zero_comment_notes():
|
||||
platform = XiaohongshuPlatform(client=None)
|
||||
payload = {
|
||||
"data": {
|
||||
"data": {
|
||||
"items": [
|
||||
{"note": {"id": "n0", "title": "无评论", "desc": "", "comments_count": 0}},
|
||||
{"note": {"id": "n1", "title": "有评论1", "desc": "d1", "comments_count": 3}},
|
||||
{"note": {"id": "n2", "title": "有评论2", "desc": "d2", "comments_count": 1}},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = platform.map_items(payload, limit=3)
|
||||
|
||||
assert [item.source_item_id for item in items] == ["n1", "n2", "n0"]
|
||||
assert all(item.item_type == "note" for item in items)
|
||||
|
||||
|
||||
def test_xiaohongshu_maps_comment_id_content_and_missing_optional_fields():
|
||||
platform = XiaohongshuPlatform(client=None)
|
||||
payload = {
|
||||
"data": {
|
||||
"data": {
|
||||
"comments": [
|
||||
{"comment_id": "preferred", "id": "fallback", "content": "正文"},
|
||||
{"id": "fallback-only", "text": "文本字段", "like_count": 7},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comments = platform.map_comments(payload, limit=10)
|
||||
|
||||
assert comments[0].source_comment_id == "preferred"
|
||||
assert comments[0].content == "正文"
|
||||
assert comments[0].like_count is None
|
||||
assert comments[0].comment_time is None
|
||||
assert comments[1].source_comment_id == "fallback-only"
|
||||
assert comments[1].content == "文本字段"
|
||||
assert comments[1].like_count == 7
|
||||
Reference in New Issue
Block a user