# -*- coding: utf-8 -*- """作品栏里的**博主**:备注(他到底是谁)与账号级指标(他现在多大)。 按 creator_hash 分组、显示 creator_name,两样都认不出人:一个是哈希,一个是平台昵称。 备注是人自己起的名字。账号级指标则是作品列表给不了的东西 —— 作品说的是"这条涨了多少赞", 粉丝数说的是"这个人整个账号在涨还是在掉"。 """ import httpx import pytest import pytest_asyncio from api.main import app from api.monitor import db as monitor_db from api.monitor.models import ( MODE_CREATOR, MonitorCreatorStat, MonitorNote, MonitorTask, ) CREATOR_HASH = "hash-a" NICKNAME = "张三" @pytest_asyncio.fixture async def client(tmp_path): monitor_db.set_sqlite_path(tmp_path / "monitor.db") await monitor_db.init_db() transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: yield http_client await monitor_db.dispose_engine() async def _seed(platform: str = "xhs", task_name: str = "任务") -> int: """一个任务 + 一条作品,博主固定用 CREATOR_HASH / NICKNAME。""" async with monitor_db.get_session() as session: task = MonitorTask( name=task_name, platform=platform, mode=MODE_CREATOR, enabled=True, interval_minutes=60, max_notes_count=20, enable_comments=False, max_comments_count=50, run_timeout_seconds=3600, notify_enabled=False, created_at=0, updated_at=0, ) session.add(task) await session.flush() session.add( MonitorNote( task_id=task.id, note_id=f"{platform}-n1", title="作品", note_url="", cover="", creator_hash=CREATOR_HASH, creator_name=NICKNAME, source_kind="", published_at=None, first_seen_run_id=1, first_seen_at=0, last_seen_run_id=1, last_seen_at=0, ) ) return task.id async def _notes(client, platform: str = "xhs"): return (await client.get("/api/monitor/notes", params={"platform": platform})).json()["notes"] async def _set_alias(client, alias: str, platform: str = "xhs", creator_hash=CREATOR_HASH): return await client.put( f"/api/monitor/creators/{creator_hash}", params={"platform": platform}, json={"alias": alias}, ) class TestCreatorAlias: @pytest.mark.asyncio async def test_notes_start_without_an_alias(self, client): await _seed() assert (await _notes(client))[0]["creator_alias"] == "" @pytest.mark.asyncio async def test_an_alias_comes_back_with_the_notes(self, client): await _seed() response = await _set_alias(client, "竞品A") assert response.status_code == 200 note = (await _notes(client))[0] assert note["creator_alias"] == "竞品A" # 备注是**叠加**在昵称之上的,不是替换 —— 昵称仍然是有用的对照。 assert note["creator_name"] == NICKNAME @pytest.mark.asyncio async def test_an_alias_is_shared_across_tasks(self, client): """同一个博主出现在两个任务里,备注只该填一次。 creator_hash 对同一个 uid 是稳定的,所以键取 (platform, creator_hash) 而不是 按任务存 —— 否则每加一个任务都要重新认一遍人。 """ await _seed(task_name="任务甲") await _seed(task_name="任务乙") await _set_alias(client, "竞品A") for note in await _notes(client): assert note["creator_alias"] == "竞品A" @pytest.mark.asyncio async def test_the_alias_does_not_leak_to_another_platform(self, client): """同一个哈希在另一个平台上是另一个(或同一个)人 —— 别串味。""" await _seed(platform="xhs") await _seed(platform="dy") await _set_alias(client, "小红书那边的", platform="xhs") assert (await _notes(client, "xhs"))[0]["creator_alias"] == "小红书那边的" assert (await _notes(client, "dy"))[0]["creator_alias"] == "" @pytest.mark.asyncio async def test_an_empty_alias_clears_it(self, client): await _seed() await _set_alias(client, "竞品A") await _set_alias(client, "") assert (await _notes(client))[0]["creator_alias"] == "" @pytest.mark.asyncio async def test_an_alias_is_trimmed(self, client): await _seed() await _set_alias(client, " 竞品A ") assert (await _notes(client))[0]["creator_alias"] == "竞品A" async def _add_stat( task_id: int, run_id: int, fans: int | None, *, total_favorited: int | None = 83000, works: int | None = 42, creator_hash: str = CREATOR_HASH, captured_at: int = 1, creator_id: str = "", ) -> None: async with monitor_db.get_session() as session: session.add( MonitorCreatorStat( task_id=task_id, run_id=run_id, creator_hash=creator_hash, nickname=NICKNAME, creator_id=creator_id, fans=fans, total_favorited=total_favorited, works_count=works, following=7, captured_at=captured_at, ) ) class TestCreatorStats: """账号级指标跟着作品一起返回 —— 界面上是按博主归组的,为了一个组头再发一轮请求 没道理。""" @pytest.mark.asyncio async def test_without_snapshots_the_fields_are_null(self, client): """null 而不是 0:0 会显示成「粉丝 0」,而事实是"还没采到"。""" await _seed() note = (await _notes(client))[0] assert note["creator_fans"] is None assert note["creator_total_favorited"] is None assert note["creator_works"] is None @pytest.mark.asyncio async def test_a_snapshot_shows_up_on_every_work_of_that_creator(self, client): task_id = await _seed() await _add_stat(task_id, run_id=1, fans=12000) for note in await _notes(client): assert note["creator_fans"] == 12000 assert note["creator_total_favorited"] == 83000 assert note["creator_works"] == 42 @pytest.mark.asyncio async def test_the_latest_run_wins(self, client): """一轮一条,所以总会有好几条 —— 给界面的必须是最近那条。""" task_id = await _seed() await _add_stat(task_id, run_id=1, fans=12000) await _add_stat(task_id, run_id=2, fans=12300) assert (await _notes(client))[0]["creator_fans"] == 12300 @pytest.mark.asyncio async def test_another_platforms_snapshot_does_not_leak(self, client): """两个平台上恰好同名同哈希的博主是两个人 —— 快照挂在任务上,不该串。""" await _seed(platform="xhs") dy_task_id = await _seed(platform="dy") await _add_stat(dy_task_id, run_id=1, fans=999) assert (await _notes(client, "xhs"))[0]["creator_fans"] is None assert (await _notes(client, "dy"))[0]["creator_fans"] == 999 @pytest.mark.asyncio async def test_an_unparsed_count_stays_null(self, client): """快照在,但某一项没解析出来 —— 那一项必须是 null,不能变成 0。 和上一条的区别:那条是"根本没有快照",这条是"有快照、其中一项平台没给"。 界面上两种都该是「—」。 """ task_id = await _seed() await _add_stat( task_id, run_id=1, fans=None, total_favorited=None, works=None ) note = (await _notes(client))[0] assert note["creator_fans"] is None assert note["creator_works"] is None # 快照本身是有的(有采集时间),只是值不知道 —— 前端要能分开这两件事。 assert note["creator_stats_at"] is not None async def _seed_task(platform: str = "xhs", task_name: str = "空任务") -> int: """只有任务,**一条作品都没有**。""" async with monitor_db.get_session() as session: task = MonitorTask( name=task_name, platform=platform, mode=MODE_CREATOR, enabled=True, interval_minutes=60, max_notes_count=20, enable_comments=False, max_comments_count=50, run_timeout_seconds=3600, notify_enabled=False, created_at=0, updated_at=0, ) session.add(task) await session.flush() return task.id async def _creators(client, platform: str = "xhs"): return (await client.get("/api/monitor/notes", params={"platform": platform})).json()["creators"] class TestCreatorList: """作品栏要显示谁 —— **包括一条作品都没有的博主**。 组原先是从作品推出来的,于是「目标加了、资料采到了、粉丝数在库里,界面上什么都 没有」。而这类博主恰恰最该看见:还在涨粉,只是最近没发东西。 """ @pytest.mark.asyncio async def test_a_creator_with_no_works_still_shows_up(self, client): """**这条就是这个改动的全部理由。**""" task_id = await _seed_task() await _add_stat(task_id, run_id=1, fans=12000) creators = await _creators(client) assert len(creators) == 1 assert creators[0]["creator_hash"] == CREATOR_HASH assert creators[0]["creator_name"] == NICKNAME # 只剩快照这一个来源 assert creators[0]["note_count"] == 0 assert creators[0]["creator_fans"] == 12000 @pytest.mark.asyncio async def test_a_creator_with_works_carries_their_count(self, client): task_id = await _seed() await _add_stat(task_id, run_id=1, fans=12000) creators = await _creators(client) assert len(creators) == 1 assert creators[0]["note_count"] == 1 assert creators[0]["creator_name"] == NICKNAME assert creators[0]["creator_fans"] == 12000 @pytest.mark.asyncio async def test_a_creator_with_neither_works_nor_a_snapshot_is_absent(self, client): """两个来源都没有 = 我们对他一无所知,不该凭空造一个组出来。""" await _seed_task() assert await _creators(client) == [] @pytest.mark.asyncio async def test_a_work_without_a_snapshot_still_lists_its_creator(self, client): """小红书那条路不产生账号快照 —— 那边只能靠作品认出人来。""" await _seed() creators = await _creators(client) assert len(creators) == 1 assert creators[0]["note_count"] == 1 assert creators[0]["creator_fans"] is None @pytest.mark.asyncio async def test_the_creator_remark_comes_along(self, client): """没有作品的博主也要能起备注 —— 否则「这是谁」在最需要的时候认不出来。""" task_id = await _seed_task() await _add_stat(task_id, run_id=1, fans=12000) await _set_alias(client, "竞品A") assert (await _creators(client))[0]["creator_alias"] == "竞品A" @pytest.mark.asyncio async def test_platforms_stay_apart(self, client): dy_task = await _seed_task(platform="dy") await _add_stat(dy_task, run_id=1, fans=999) assert await _creators(client, "xhs") == [] assert (await _creators(client, "dy"))[0]["creator_fans"] == 999 @pytest.mark.asyncio async def test_the_same_creator_under_two_tasks_is_two_rows_the_ui_merges(self, client): """服务端按 任务×博主 给(快照就是那么存的),合并交给界面 —— 因为备注跨任务 是同一条,合并之后的组才是人眼里的「一个博主」。""" first = await _seed(task_name="任务甲") second = await _seed(task_name="任务乙") await _add_stat(first, run_id=1, fans=12000) creators = await _creators(client) assert len(creators) == 2 assert {row["creator_hash"] for row in creators} == {CREATOR_HASH} assert sum(row["note_count"] for row in creators) == 2 class TestCreatorProfileLink: """组头上那个「跳到他主页」的按钮。 它要的是**平台上的主页 id**,不是 ``creator_hash``(哈希单向,拼不出地址)。所以 这个 id 是采集资料时单独存下来的;拿不到就返回空串让界面不画按钮。 """ @pytest.mark.asyncio async def test_the_link_is_built_from_the_stored_id(self, client): task_id = await _seed_task(platform="dy") await _add_stat(task_id, run_id=1, fans=1, creator_id="MS4wLjABAAAAsomebody") creators = await _creators(client, "dy") assert creators[0]["creator_url"] == ( "https://www.douyin.com/user/MS4wLjABAAAAsomebody" ) @pytest.mark.asyncio async def test_no_id_means_no_link(self, client): """没采到主页 id 就不给链接 —— 画一个点开 404 的按钮比不画糟。""" task_id = await _seed_task(platform="dy") await _add_stat(task_id, run_id=1, fans=1, creator_id="") assert (await _creators(client, "dy"))[0]["creator_url"] == "" @pytest.mark.asyncio async def test_a_creator_with_only_works_has_no_link_either(self, client): """小红书那条路根本不落创作者资料,所以那边永远是空的 —— 这是已知的、可接受的。""" await _seed(platform="xhs") assert (await _creators(client, "xhs"))[0]["creator_url"] == "" @pytest.mark.asyncio async def test_a_creator_with_no_works_still_gets_the_link(self, client): """**没有作品的博主照样能跳过去** —— 他恰恰是你想点进去看看发的什么的那个人。""" task_id = await _seed_task(platform="dy") await _add_stat(task_id, run_id=1, fans=1, creator_id="MS4wLjABAAAAquiet") creators = await _creators(client, "dy") assert creators[0]["note_count"] == 0 assert creators[0]["creator_url"].endswith("/user/MS4wLjABAAAAquiet")