diff --git a/api/monitor/report.py b/api/monitor/report.py index 7467a2b..10691ce 100644 --- a/api/monitor/report.py +++ b/api/monitor/report.py @@ -135,7 +135,11 @@ async def build_report( start_ms, _ = day_bounds(start_day) _, end_ms = day_bounds(end_day) - scope = list(task_ids) if task_ids else None + # 必须是 `is not None`,不能写 `if task_ids` —— **空列表是假值**,而空列表在这里 + # 的含义是「这个平台一个任务都没有」,不是「不限制平台」。用真值判断的话, + # 切到一个还没有任务的平台,报表会把**所有**任务的数据聚合出来(看起来就是 + # 「抖音的报表里全是小红书的数据」)。 + scope = list(task_ids) if task_ids is not None else None days = iter_days(start_day, end_day) # Fetch every snapshot up to the range end: the delta on the first day needs diff --git a/tests/test_monitor_report.py b/tests/test_monitor_report.py index f71726b..20c5479 100644 --- a/tests/test_monitor_report.py +++ b/tests/test_monitor_report.py @@ -184,9 +184,9 @@ async def db(): await engine.dispose() -async def _seed_task(db: AsyncSession, name: str) -> MonitorTask: +async def _seed_task(db: AsyncSession, name: str, platform: str = "xhs") -> MonitorTask: task = MonitorTask( - name=name, platform="xhs", mode=MODE_CREATOR, enabled=True, + name=name, platform=platform, mode=MODE_CREATOR, enabled=True, interval_minutes=60, max_notes_count=20, enable_comments=True, max_comments_count=50, run_timeout_seconds=3600, notify_enabled=False, created_at=0, updated_at=0, @@ -263,6 +263,25 @@ class TestBuildReport: assert result["totals"]["liked_count_delta"] == 30 assert result["task_ids"] is None + @pytest.mark.asyncio + async def test_an_empty_selection_is_not_the_same_as_no_filter(self, db): + """空列表 ≠ 不限制。 + + ``_resolve_scope`` 在「这个平台一个任务都没有」时返回**空列表**。如果按真值 + 处理(``if task_ids``),报表就会退化成「不限制平台」,把**所有**任务的数据 + 聚合进来 —— 现象就是切到抖音,报表里却全是小红书的数据。 + """ + other = await _seed_task(db, "xhs task", platform="xhs") + await _seed_note_with_metrics(db, other, "n1", [(_ms(2026, 1, 10, 10), 999)]) + await db.commit() + + result = await build_report(db, [], date(2026, 1, 10), date(2026, 1, 10)) + + assert result["totals"]["liked_count_delta"] == 0 + assert result["note_count"] == 0 + # 空列表要原样透出去;None 在 API 里的意思是「全部任务」,两者不能混。 + assert result["task_ids"] == [] + @pytest.mark.asyncio async def test_baseline_from_before_the_range_is_used(self, db): """Growth is measured against the last value before the window opens.""" diff --git a/tests/test_platforms.py b/tests/test_platforms.py index f9bc74f..355ab54 100644 --- a/tests/test_platforms.py +++ b/tests/test_platforms.py @@ -21,13 +21,15 @@ import httpx import pytest import pytest_asyncio -from sqlalchemy import text +from sqlalchemy import select, text + +from tools.time_util import get_current_timestamp from api.main import app from api.monitor import adapters from api.monitor import db as monitor_db from api.monitor import platforms -from api.monitor.models import MonitorTask +from api.monitor.models import MonitorNote, MonitorNoteMetric, MonitorTask XHS_TARGET = "5f58bd990000000001003753" @@ -44,6 +46,33 @@ async def client(tmp_path): await monitor_db.dispose_engine() +async def _seed_note_with_one_snapshot(task_name: str) -> None: + """给某个任务塞一条作品和一次指标快照。 + + 过滤类测试**必须有真数据**才有意义 —— 库里空着的话,过滤有没有生效结果都是 0, + 测试就变成了空跑(这个坑踩过一次:一个报表串数据的 bug 因此没被拦住)。 + """ + async with monitor_db.get_session() as session: + task = await session.scalar(select(MonitorTask).where(MonitorTask.name == task_name)) + now = get_current_timestamp() + session.add( + MonitorNote( + task_id=task.id, note_id="seed-note", title="seed", note_url="", + cover="", creator_hash="", source_kind="", published_at=None, + first_seen_run_id=1, first_seen_at=now, + last_seen_run_id=1, last_seen_at=now, + ) + ) + session.add( + MonitorNoteMetric( + task_id=task.id, note_id="seed-note", run_id=1, captured_at=now, + liked_count=42, comment_count=0, collected_count=0, share_count=0, + raw_liked_count="42", raw_comment_count="0", + raw_collected_count="0", raw_share_count="0", + ) + ) + + class TestCapabilityMatrix: @pytest.mark.asyncio async def test_matrix_is_exposed_to_the_ui(self, client): @@ -191,8 +220,15 @@ class TestPlatformScoping: @pytest.mark.asyncio async def test_a_platform_with_no_tasks_yields_empty_not_everything(self, client): - """An empty task set must not degrade into "no filter".""" + """空的任务集合不能退化成「不加过滤」。 + + **这里必须真的有数据。** 没有数据时,过滤生效与否结果都是 0 —— 这条测试原先 + 就栽在这个空跑上,所以没能拦下一个报表串数据的 bug(切到抖音,报表里却出现 + 小红书的数据)。最后那段「小红书自己的报表看得到」就是为了证明这些数据确实 + 存在、上面那两个 0 是过滤出来的。 + """ await self._seed_two_platforms(client) + await _seed_note_with_one_snapshot("小红书任务") body = (await client.get("/api/monitor/notes", params={"platform": "bili"})).json() assert body["notes"] == [] @@ -203,6 +239,11 @@ class TestPlatformScoping: assert report["totals"]["liked_count_delta"] == 0 assert report["note_count"] == 0 + xhs = ( + await client.get("/api/monitor/report", params={"platform": "xhs"}) + ).json() + assert xhs["note_count"] == 1 + class TestPerPlatformSettings: @pytest.mark.asyncio