Ctrl K
ComunidadGuías…

tests/test_follow_creators.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""关注作者建档(ADR-0096):建谁、等谁、并进谁,链接从哪来,认人与按批撤回。"""
import contextlib
import io
import json
import sqlite3
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path

from peach import follow_creators, web_follow
from peach.follow_sources import FollowCandidate, SourceFetch
from peach.follow_store import FollowStore
from peach.web_contract import WebContract, dispatch_api_get, dispatch_api_post
from peach.web_entity import q_entity
from peach.follow_identity import author_key
from scripts import revert_auto_landing
from support.ledger import fresh_ledger

MOMENT = datetime(2026, 9, 1, tzinfo=timezone.utc)


class FollowCreatorTests(unittest.TestCase):
    def setUp(self):
        self.temporary = tempfile.TemporaryDirectory()
        self.addCleanup(self.temporary.cleanup)
        self.root = Path(self.temporary.name).resolve()
        self.db = fresh_ledger(self.root)
        self.contract = WebContract(
            self.db, follow_sources_root=self.root / "sources",
            follow_secrets_root=self.root / "secrets", follow_shared_root=self.root / "shared",
            candidate_root=self.root / "generated", follow_state_root=self.root / "state")

    def source(self, provider, ref, label, metadata=None, items=1):
        with self.contract.database.write_transaction() as connection:
            store = FollowStore(lambda: connection, sources_root=self.contract.follow_sources_root)
            source_id = store.register(provider=provider, ref=ref, label=label,
                                       url=f"https://{provider}.test/{ref}", metadata=metadata,
                                       moment=MOMENT)
            candidates = tuple(FollowCandidate(
                provider=provider, external_id=f"{source_id}-{at}", title=f"{label} {source_id}-{at}",
                url=f"https://{provider}.test/{ref}/{at}") for at in range(items))
            store.record(source_id, SourceFetch(
                provider=provider, ref=ref, request_url=f"https://{provider}.test/{ref}",
                semantics="work", candidates=candidates, raw_body=b"<html/>"), moment=MOMENT)
        return source_id

    def creator(self, name):
        with self.contract.database.write_transaction() as connection:
            return int(connection.execute(
                "INSERT INTO entity(kind,canonical_name,normalized_name,created_at,updated_at)"
                " VALUES('creator',?,?,?,?)", (name, name.casefold(), "2026", "2026")).lastrowid)

    def plan(self):
        with self.contract.database.read_connection() as connection:
            return {landing.key: landing for landing in follow_creators.plan(connection)}

    def run_followup(self):
        return follow_creators.run(self.contract, "follow-creator:all", None)

    def read(self, sql, *args):
        with self.contract.database.read_connection() as connection:
            return [tuple(row) for row in connection.execute(sql, args)]

    def jul3d(self):
        self.source("kemono", "fanbox/12345", "Jul3D", items=2)
        self.source("kemono", "patreon/777", "Jul3D")
        self.source("f95zone", "123", "Jul3D Collection", metadata={
            "author_key": "jul3d",
            "official_links": [
                {"service": "patreon", "handle": "jul3d", "url": "https://www.patreon.com/jul3d/posts"},
                {"service": "twitter", "handle": "Jul3D_", "url": "http://twitter.com/Jul3D_?s=20"},
                {"service": "discord", "handle": "abc", "url": "https://discord.gg/abc"},
            ]})

    def test_a_new_author_becomes_a_creator_with_its_channels_and_socials(self):
        self.jul3d()
        landing = self.plan()["name:jul3d"]
        self.assertEqual((landing.action, landing.name), ("create", "Jul3D"))
        self.assertEqual(sorted(link["url"] for link in landing.links), [
            "https://www.patreon.com/jul3d", "https://www.pixiv.net/users/12345", "https://x.com/Jul3D_"])

        result = self.run_followup()
        self.assertEqual((result["created"], result["bound"], result["links"]), (1, 3, 3))
        entity_id, metadata = self.read(
            "SELECT id,metadata_json FROM entity WHERE canonical_name='Jul3D'")[0]
        self.assertEqual(json.loads(metadata)["source"], follow_creators.SOURCE)
        self.assertEqual(sorted(self.read("SELECT link_kind,label FROM entity_link WHERE entity_id=?", entity_id)),
                         [("official", "Patreon"), ("social", "X"), ("social", "pixiv")])
        self.assertEqual({row[0] for row in self.read("SELECT entity_id FROM follow_source")}, {entity_id})
        # 重跑是一张空计划。
        self.assertEqual(self.run_followup()["created"], 0)

        authors = dispatch_api_get(self.contract, "/api/follow/authors", {})["items"]
        self.assertEqual([(row["k"], row["key"], row["entity_id"]) for row in authors],
                         [("Jul3D", f"entity:{entity_id}", entity_id)])
        # 资料页的读数与名册那一格数的是同一批更新。
        page = q_entity(self.contract, {"kind": "creator", "name": "Jul3D"})
        self.assertEqual((page["follow"]["key"], page["follow"]["n"], page["follow"]["held"]),
                         (f"entity:{entity_id}", authors[0]["n"], []))

    def test_a_later_source_under_the_same_name_key_joins_the_existing_creator(self):
        self.jul3d()
        self.run_followup()
        later = self.source("kemono", "fanbox/99999", "Jul3D")
        entity_id = self.read("SELECT id FROM entity WHERE canonical_name='Jul3D'")[0][0]
        landing = self.plan()["name:jul3d"]
        self.assertEqual((landing.action, landing.entity_id), ("join", entity_id))
        with self.contract.database.read_connection() as connection:
            self.assertEqual(follow_creators.declare(connection)[0]["task_key"], "follow-creator")
        self.run_followup()
        self.assertEqual(self.read("SELECT entity_id FROM follow_source WHERE id=?", later), [(entity_id,)])
        self.assertIn(("https://www.pixiv.net/users/99999",),
                      self.read("SELECT url FROM entity_link WHERE entity_id=?", entity_id))

    def test_a_same_named_creator_is_only_offered_until_the_user_confirms(self):
        held_id = self.creator("InitialA")
        source_id = self.source("kemono", "fanbox/4242", "InitialA", items=3)
        landing = self.plan()["name:initiala"]
        self.assertEqual((landing.action, landing.entity_id), ("hold", held_id))
        with self.contract.database.read_connection() as connection:
            self.assertEqual(follow_creators.declare(connection), [])
        self.run_followup()
        self.assertEqual(self.read("SELECT entity_id FROM follow_source"), [(None,)])

        authors = dispatch_api_get(self.contract, "/api/follow/authors", {})["items"]
        self.assertEqual([(row["key"], row["held_by"]) for row in authors], [("name:initiala", "InitialA")])
        follow = q_entity(self.contract, {"kind": "creator", "name": "InitialA"})["follow"]
        self.assertEqual(follow["key"], "")
        self.assertEqual([(group["key"], group["n"]) for group in follow["held"]], [("name:initiala", 3)])

        written = dispatch_api_post(self.contract, "/api/follow/creator",
                                    {"entity_id": held_id, "key": "name:initiala"})
        self.assertEqual((written["bound"], written["links"]), (1, 1))
        self.assertEqual(self.read("SELECT entity_id FROM follow_source WHERE id=?", source_id), [(held_id,)])
        link_metadata = json.loads(self.read("SELECT metadata_json FROM entity_link")[0][0])
        self.assertEqual(link_metadata["source"], follow_creators.CONFIRM_SOURCE)
        follow = q_entity(self.contract, {"kind": "creator", "name": "InitialA"})["follow"]
        self.assertEqual((follow["key"], follow["held"]), (f"entity:{held_id}", []))
        with self.assertRaises(ValueError):
            dispatch_api_post(self.contract, "/api/follow/creator", {"entity_id": held_id, "key": "name:initiala"})

    def test_revert_unbinds_the_batch_and_keeps_creators_people_rely_on(self):
        self.jul3d()
        held_id = self.creator("InitialA")
        self.source("kemono", "fanbox/4242", "InitialA")
        self.run_followup()
        dispatch_api_post(self.contract, "/api/follow/creator", {"entity_id": held_id, "key": "name:initiala"})
        with contextlib.redirect_stdout(io.StringIO()):
            revert_auto_landing.main(["--db", str(self.db), "--apply", "--backup", str(self.root / "backup.db"),
                                      "--source", follow_creators.SOURCE])
        self.assertEqual(self.read("SELECT canonical_name FROM entity WHERE kind='creator'"), [("InitialA",)])
        # 人认过的那一组不归自动撤回。
        self.assertEqual(sorted(row[0] or 0 for row in self.read("SELECT entity_id FROM follow_source")),
                         [0, 0, 0, held_id])
        self.assertEqual(len(self.read("SELECT id FROM entity_link")), 1)
        with contextlib.closing(sqlite3.connect(self.db)) as connection:
            self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])

    def test_the_follow_check_declares_the_followup_only_while_someone_is_waiting(self):
        self.jul3d()
        result = web_follow._run_follow_check(self.contract, {"sources": []})
        self.assertEqual([row["key"] for row in result["followups"]], ["follow-creator:all"])
        self.run_followup()
        self.assertEqual(web_follow._run_follow_check(self.contract, {"sources": []})["followups"], [])

    def test_author_key_follows_the_binding(self):
        self.jul3d()
        self.run_followup()
        with self.contract.database.read_connection() as connection:
            rows = FollowStore(lambda: connection).sources()
        self.assertEqual({author_key(row) for row in rows},
                         {f"entity:{rows[0]['entity_id']}"})


class SourceLinkTests(unittest.TestCase):
    def row(self, provider, ref, metadata=None, url=""):
        return {"provider": provider, "ref": ref, "url": url, "label": ref,
                "metadata_json": json.dumps(metadata or {})}

    def urls(self, row):
        return [link["url"] for link in follow_creators.source_links(row)]

    def test_archives_restore_only_the_account_shapes_that_were_checked(self):
        self.assertEqual(self.urls(self.row("kemono", "fanbox/123")), ["https://www.pixiv.net/users/123"])
        self.assertEqual(self.urls(self.row("coomer", "onlyfans/some.one_")), ["https://onlyfans.com/some.one_"])
        self.assertEqual(self.urls(self.row("kemono", "fanbox/name")), [])
        self.assertEqual(self.urls(self.row("kemono", "gumroad/123")), [])

    def test_an_official_source_is_its_own_channel(self):
        self.assertEqual(self.urls(self.row("subscribestar", "artist", url="https://subscribestar.adult/artist")),
                         ["https://subscribestar.adult/artist"])

    def test_booru_profile_links_count_only_when_the_handle_is_the_author(self):
        row = self.row("rule34video", "artist", {"official_links": [
            {"service": "twitter", "handle": "artist", "url": "https://x.com/artist"},
            {"service": "twitter", "handle": "collaborator", "url": "https://x.com/collaborator"}]})
        self.assertEqual(self.urls(row), ["https://x.com/artist"])

    def test_subscribestar_keeps_the_host_from_the_profile(self):
        row = self.row("f95zone", "1", {"official_links": [
            {"service": "subscribestar", "handle": "artist", "url": "https://www.subscribestar.com/artist?x=1"}]})
        self.assertEqual(self.urls(row), ["https://subscribestar.com/artist"])


if __name__ == "__main__":
    unittest.main()