Ctrl K
ComunidadGuías…

tests/test_follow_connector_base.py

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

Archivo original ↗Preguntar a KiBot
"""连接器基类共享骨架的隔离测试。

「条件请求 → 304 短路 → 状态检查」由 `_BaseConnector._request()` 一处实现,
八个连接器的 fetch 开头不各写一份,所以这些规则也只需要在这里守住;
各站的解析差异仍由 `test_follow_sources.py` 覆盖。测试不联网,transport 全部注入。
"""
import unittest
import httpx

from peach.follow import FollowSourceError
from peach.follow_sources import (
    CONNECTORS, KemonoConnector, _BaseConnector, display_thumb_url,
    is_history_end_error, official_profile_handle,
)
from peach.follow_store import _OFFICIAL_IDENTITY_PROVIDERS
from peach.http import HttpResponse


def _transport(status=200, headers=None, body=b"", record=None):
    def call(request, timeout, max_bytes):
        if record is not None:
            record.append(request)
        return HttpResponse(status, headers or {}, body)
    return call


class _Probe(_BaseConnector):
    provider = "probe"


class SharedRequestSkeletonTests(unittest.TestCase):
    def test_handshake_timeout_recovers_with_bounded_backoff(self):
        calls, waits, progress = [], [], []
        def transport(request, timeout, max_bytes):
            calls.append(request)
            if len(calls) < 5:
                raise httpx.ConnectTimeout('_ssl.c:1064: The handshake operation timed out')
            return HttpResponse(200, {}, b'{}')
        connector = _Probe(transport=transport, sleeper=waits.append)
        connector.progress = lambda **state: progress.append(state)
        self.assertEqual(connector._get('https://example.test').status, 200)
        self.assertEqual(waits, [1, 2, 4, 8])
        self.assertEqual(progress[-1]['attempt'], 5)

    def test_retries_exhaust_and_post_is_not_replayed(self):
        for method, expected in [('GET', 5), ('POST', 1)]:
            calls = []
            def transport(*args, calls=calls):
                calls.append(1)
                raise httpx.ReadTimeout('temporary')
            connector = _Probe(transport=transport, sleeper=lambda _: None)
            with self.assertRaises(FollowSourceError):
                connector._send(method, 'https://example.test', None, headers={}, base={})
            self.assertEqual(len(calls), expected)

    def test_access_denied_is_not_retried(self):
        calls, waits = [], []
        connector = _Probe(transport=_transport(status=403, record=calls), sleeper=waits.append)
        with self.assertRaises(FollowSourceError):
            connector._request('https://example.test', ref='r')
        self.assertEqual(len(calls), 1)
        self.assertEqual(waits, [])

    def test_first_page_sends_conditional_headers(self):
        seen = []
        connector = _Probe(transport=_transport(body=b"{}", record=seen))
        common, response = connector._request(
            "https://example.test/a", ref="r", etag='"v0"',
            last_modified="Wed, 13 Aug 2026 08:00:00 GMT")
        self.assertIsNotNone(response)
        self.assertEqual(seen[0].headers.get("If-None-Match"), '"v0"')
        self.assertEqual(seen[0].headers.get("If-Modified-Since"),
                         "Wed, 13 Aug 2026 08:00:00 GMT")
        self.assertEqual(common["ref"], "r")
        self.assertEqual(common["request_url"], "https://example.test/a")

    def test_paging_back_drops_conditional_headers(self):
        """往回翻页不能带条件请求头。

        `If-None-Match` 存的是第一页的 etag,拿它去问第二页,站点很可能回 304,
        表现就是「点了没反应」。这条规则过去在每个连接器里各写一遍,现在只有一处。"""
        seen = []
        connector = _Probe(transport=_transport(body=b"{}", record=seen))
        connector._request("https://example.test/a?o=50", ref="r", etag='"v0"',
                           last_modified="Wed, 13 Aug 2026 08:00:00 GMT", page=1)
        self.assertNotIn("If-None-Match", seen[0].headers)
        self.assertNotIn("If-Modified-Since", seen[0].headers)

    def test_not_modified_short_circuits_with_usable_common(self):
        connector = _Probe(transport=_transport(status=304, headers={"ETag": '"v1"'}))
        common, response = connector._request("https://example.test/a", ref="r")
        self.assertIsNone(response, "304 必须短路,调用方据此直接返回 not_modified")
        self.assertEqual(common["etag"], '"v1"')
        self.assertEqual(common["provider"], "probe")

    def test_request_url_can_be_masked_so_credentials_never_reach_evidence(self):
        """带凭据的真实 URL 绝不能落进证据档案。

        rule34xxx 的 `api_key` 在查询串里,必须传脱敏版本;`_request` 请求用真实 URL,
        `common` 里放脱敏 URL。"""
        seen = []
        connector = _Probe(transport=_transport(body=b"{}", record=seen))
        common, _ = connector._request(
            "https://example.test/a?api_key=secret", ref="r",
            request_url="https://example.test/a")
        self.assertIn("api_key=secret", seen[0].url, "请求本身仍要带真实凭据")
        self.assertNotIn("secret", str(common["request_url"]))

    def test_non_200_raises_before_the_caller_parses_anything(self):
        waits = []
        connector = _Probe(transport=_transport(status=500), sleeper=waits.append)
        with self.assertRaises(FollowSourceError):
            connector._request("https://example.test/a", ref="r")
        self.assertEqual(waits, [1, 2, 4, 8])


class SharedSendContractTests(unittest.TestCase):
    """`_get` 与 `_post` 只在动词和请求体上不同,其余规则必须是同一份实现。

    两份复制品已经开始分岔:`connector_headers=False`(跳到第三方主机时不带来源站
    Cookie)当初只加进了 GET 那一份。那是安全语义,不该取决于用的是哪个动词。
    """

    class _Blocked(_BaseConnector):
        provider = "blocked"
        blocked_reason = "站点有机器人验证"

    def test_both_verbs_refuse_a_blocked_site(self):
        connector = self._Blocked(transport=_transport())
        for call in (lambda: connector._get("https://example.test/a"),
                     lambda: connector._post("https://example.test/a", b"x")):
            with self.assertRaises(FollowSourceError):
                call()

    def test_both_verbs_reject_a_body_over_the_size_bound(self):
        connector = _Probe(transport=_transport(body=b"x" * 40), max_bytes=8)
        for call in (lambda: connector._get("https://example.test/a"),
                     lambda: connector._post("https://example.test/a", b"x")):
            with self.assertRaises(FollowSourceError):
                call()

    def test_both_verbs_can_drop_the_source_site_headers(self):
        """跨站请求不带来源站的 Cookie,POST 也一样。"""
        class _WithCookie(_BaseConnector):
            provider = "withcookie"

            def _headers(self):
                return {**super()._headers(), "Cookie": "session=secret"}

        seen = []
        connector = _WithCookie(transport=_transport(body=b"{}", record=seen))
        connector._get("https://third.test/a", connector_headers=False)
        connector._post("https://third.test/a", b"x", connector_headers=False)
        for request in seen:
            self.assertNotIn("Cookie", request.headers)
        connector._post("https://own.test/a", b"x")
        self.assertEqual(seen[-1].headers.get("Cookie"), "session=secret")

    def test_post_carries_the_body_and_get_does_not(self):
        seen = []
        connector = _Probe(transport=_transport(body=b"{}", record=seen))
        connector._get("https://example.test/a")
        connector._post("https://example.test/a", b"payload")
        self.assertEqual([(row.method, row.body) for row in seen],
                         [("GET", None), ("POST", b"payload")])

    def test_the_conditional_header_still_overrides_an_explicit_one(self):
        """`etag` 盖掉调用方显式给的同名头,合并顺序与抽取前一致。"""
        seen = []
        connector = _Probe(transport=_transport(body=b"{}", record=seen))
        connector._get("https://example.test/a", headers={"If-None-Match": '"old"'},
                       etag='"new"')
        self.assertEqual(seen[0].headers.get("If-None-Match"), '"new"')


class _Item:
    """已入库的一行里,读取时投影用得到的那几个字段。"""

    def __init__(self, provider, *, thumb_url=None, media_url=None, item_id=1, metadata=None):
        self.provider = provider
        self.thumb_url = thumb_url
        self.media_url = media_url
        self.id = item_id
        self.metadata = metadata or {}


class DisplayThumbUrlTests(unittest.TestCase):
    """「这个站的缩略图 URL 长什么样」属于连接器,不属于 Web 层。

    rule34.xxx 的 preview→sample 改写、归档站的 `img.` 子域、归档站旧行的缩略图
    推导,都归连接器。站点行为变了要改的是站点知识,而 Web 层根本不该知道
    `api-cdn.rule34.xxx` 这种主机名。
    """

    def test_rule34xxx_images_use_the_source_file_and_extension(self):
        """图片使用来源声明的原文件,视频使用来源缩略图。"""
        thumb = ("https://api-cdn.rule34.xxx/thumbnails/1234/"
                 "thumbnail_" + "a" * 32 + ".jpg")
        image = "https://api-cdn.rule34.xxx/images/1234/" + "a" * 32 + ".png"
        self.assertEqual(display_thumb_url(_Item("rule34xxx", thumb_url=thumb,
            media_url=image)), image)
        self.assertEqual(display_thumb_url(_Item("rule34xxx", thumb_url=thumb,
            media_url='https://api-cdn.rule34.xxx/images/128/' + 'c' * 32 + '.mp4')), thumb)

    def test_a_rule34xxx_thumb_in_another_shape_is_left_alone(self):
        """认不出的形状原样返回,不猜一个 bucket 出来。"""
        other = "https://api-cdn.rule34.xxx/images/1/x.jpg"
        self.assertEqual(display_thumb_url(_Item("rule34xxx", thumb_url=other)), other)

    def test_archive_thumbnails_use_the_img_subdomain(self):
        """2026-08-30 实测:主域 kemono.cr 回 302、pawchive.pw 回 404,img. 都回 200。"""
        for provider, host in (("kemono", "kemono.cr"), ("pawchive", "pawchive.pw"),
                               ("coomer", "coomer.st")):
            recorded = f"https://{host}/thumbnail/data/a/b/c.jpg"
            fixed = f"https://img.{host}/thumbnail/data/a/b/c.jpg"
            self.assertEqual(display_thumb_url(_Item(provider, thumb_url=recorded)),
                             fixed)
            # 已经是 img. 的不再叠加
            self.assertEqual(display_thumb_url(_Item(provider, thumb_url=fixed)), fixed)

    def test_an_archive_thumb_on_a_foreign_host_is_not_rewritten(self):
        self.assertEqual(
            display_thumb_url(_Item("kemono", thumb_url="https://example.test/a.jpg")),
            "https://example.test/a.jpg")

    def test_an_archive_row_without_a_thumb_derives_one_from_the_image_media(self):
        """封面修复前入库的旧行 thumb_url 为空,但 media_url 就是图片本身。

        库里两种 `media_url` 形状都有:那次媒体主机修复之前拼的没有 `/data` 前缀,
        之后拼的有。`/thumbnail/data` 里已经带了一个 `data`,所以拼之前要剥掉,
        否则新形状的行会得到 `/thumbnail/data/data/...` 这种必然 404 的地址。
        """
        for media in ("https://pawchive.pw/a/b/c.jpg",
                      "https://file.pawchive.pw/data/a/b/c.jpg"):
            self.assertEqual(
                display_thumb_url(_Item("pawchive", media_url=media)),
                "https://img.pawchive.pw/thumbnail/data/a/b/c.jpg", media)

    def test_a_video_only_archive_row_gets_no_derived_thumb(self):
        """视频没有可推导的缩略图,给了也是 404——卡片宁可显示干净的占位。"""
        self.assertIsNone(display_thumb_url(_Item(
            "kemono", media_url="https://kemono.cr/data/a/b/c.mp4")))

    def test_a_provider_without_a_rule_uses_the_recorded_thumb(self):
        for provider in ("rule34video", "f95zone", "nyaa"):
            self.assertEqual(
                display_thumb_url(_Item(provider, thumb_url="https://x.test/a.jpg")),
                "https://x.test/a.jpg")
        self.assertIsNone(display_thumb_url(_Item("rule34video")))


class HistoryEndRecognitionTests(unittest.TestCase):
    """哪些状态码代表「往回翻到尽头」只声明一次,翻页判定和旧行识别共用。

    `record_history_end` 之前的版本把翻到尽头记成了 `error`,正文就是
    `_check_status` 那句话。旧的识别代码是在 Web 层照站点名硬编码中文串比较,
    新增一个可回填来源时没人会想到还要改那一处。
    """

    def test_a_declared_status_is_recognised_and_others_are_not(self):
        self.assertTrue(is_history_end_error("kemono", "kemono 返回 HTTP 400"))
        self.assertTrue(is_history_end_error("coomer", "coomer 返回 HTTP 404"))
        self.assertTrue(is_history_end_error("rule34paheal",
                                             "rule34paheal 返回 HTTP 404"))
        self.assertFalse(is_history_end_error("kemono", "kemono 返回 HTTP 500"))
        self.assertFalse(is_history_end_error("rule34video",
                                             "rule34video 返回 HTTP 400"))

    def test_a_provider_that_never_pages_back_recognises_nothing(self):
        for message in ("f95zone 返回 HTTP 404", "f95zone 返回 HTTP 400"):
            self.assertFalse(is_history_end_error("f95zone", message))
        self.assertFalse(is_history_end_error("nyaa", "nyaa 返回 HTTP 404"))

    def test_it_matches_the_message_check_status_actually_writes(self):
        """判据必须对上真正落盘的那句话。

        识别的是 `_check_status` 生成的文本。这两处一旦分家,回填到底的来源会
        永远显示成红色错误行,而且不会有任何测试变红——所以这里现场生成一次。
        """
        connector = KemonoConnector(provider="kemono",
                                    transport=_transport(status=400))
        with self.assertRaises(FollowSourceError) as raised:
            connector._check_status(HttpResponse(400, {}, b""))
        self.assertTrue(is_history_end_error("kemono", str(raised.exception)),
                        f"没认出来:{raised.exception}")

    def test_an_unrelated_error_is_not_swallowed(self):
        for message in ("kemono 请求失败:connect timeout",
                        "kemono 的帖子列表格式不符", ""):
            self.assertFalse(is_history_end_error("kemono", message))


class PublicProbeApiTests(unittest.TestCase):
    """`follow_discovery` 与 `web_follow` 要的是探测能力,不该去摸私有方法。"""

    def test_probe_returns_the_response_without_checking_status(self):
        """发现流程按状态码判断「作者页在不在」,404 是答案不是故障。"""
        connector = _Probe(transport=_transport(status=404))
        self.assertEqual(connector.probe("https://example.test/a").status, 404)

    def test_fetch_json_parses_and_rejects_non_200(self):
        connector = _Probe(transport=_transport(body=b'{"name": "x"}'))
        self.assertEqual(connector.fetch_json("https://example.test/a"), {"name": "x"})
        waits = []
        failing = _Probe(transport=_transport(status=503, body=b"{}"), sleeper=waits.append)
        with self.assertRaises(FollowSourceError):
            failing.fetch_json("https://example.test/a")
        self.assertEqual(waits, [1, 2, 4, 8])

    def test_parse_json_rejects_a_non_json_body(self):
        connector = _Probe(transport=_transport(body=b"<html/>"))
        with self.assertRaises(FollowSourceError):
            connector.parse_json(connector.probe("https://example.test/a"))

    def test_every_connector_inherits_the_public_probe_api(self):
        for provider, factory in CONNECTORS.items():
            for name in ("probe", "fetch_json", "parse_json"):
                self.assertTrue(
                    hasattr(factory, name) or hasattr(KemonoConnector, name),
                    f"{provider} 缺少公开的 {name}",
                )


class NoPrivateReachIntoConnectorsTests(unittest.TestCase):
    def test_other_modules_never_call_connector_private_methods(self):
        """连接器之外的模块只能用公开 API。

        缺少「探测」这类公开入口时,调用方会直接摸 `_get`/`_check_status`/`_json`,
        于是基类的任何改动都可能悄悄弄坏发现流程。这条门槛把它挡在源码层面。"""
        import pathlib

        import peach

        root = pathlib.Path(peach.__file__).parent
        offenders = []
        for path in sorted(root.glob("*.py")):
            if path.name == "follow_sources.py":
                continue  # 基类自己的实现文件
            text = path.read_text(encoding="utf-8")
            for private in ("connector._get(", "connector._check_status(",
                            "connector._json(", "connector._post("):
                if private in text:
                    offenders.append(f"{path.name}: {private}")
        self.assertEqual(
            offenders, [],
            "改用 probe()/fetch_json()/parse_json();缺能力就在基类补公开方法",
        )


class ProfileHandleTests(unittest.TestCase):
    """ref 里哪一截是作者本人的手柄,由各站自己的连接器说。

    这些规则不写在 `web_follow` 的一串 if/elif 里:「ref 长什么形状」正是连接器
    已经在解析、在校验的东西,同一份知识分在两层,改一处就会漂移。
    """

    def test_an_official_channel_ref_is_the_author_handle(self):
        cases = {
            "fanbox": ("ffxivinitiala", "ffxivinitiala"),
            "subscribestar": ("subscribestar.adult/initiala", "initiala"),
            "patreon": ("initiala", "initiala"),
        }
        for provider, (ref, expected) in cases.items():
            with self.subTest(provider=provider):
                self.assertEqual(official_profile_handle(provider, ref), expected)

    def test_a_numeric_patreon_user_page_has_no_handle(self):
        """`user/12345` 是内部 id,不是名字;学成别名会造出一个假作者。"""
        for ref in ("user/12345", "12345", "/user/12345/"):
            self.assertEqual(official_profile_handle("patreon", ref), "")

    def test_archive_and_tag_sites_offer_no_handle_at_all(self):
        cases = (("kemono", "fanbox/30917150"), ("coomer", "onlyfans/x"),
                 ("pawchive", "fanbox/1"), ("rule34xxx", "lazyprocrastinator"),
                 ("rule34video", "lazyprocrastinator"), ("f95zone", "50685"),
                 ("rule34paheal", "tag"), ("simpcity", "thread"))
        for provider, ref in cases:
            with self.subTest(provider=provider):
                self.assertEqual(official_profile_handle(provider, ref), "")

    def test_every_connector_answers_this_question(self):
        """基类给了默认值,所以新增站点不会因为漏实现而炸在检查更新的中途。"""
        for provider, factory in CONNECTORS.items():
            with self.subTest(provider=provider):
                self.assertIsInstance(factory.profile_handle("anything"), str)
        self.assertEqual(official_profile_handle("not-registered", "x"), "")

    def test_only_the_official_identity_providers_actually_return_one(self):
        """能返回手柄的站必须正好是 `_OFFICIAL_IDENTITY_PROVIDERS` 那一组。

        这两处一旦不一致,就会出现「明明解析出了手柄却永远不学」或者反过来
        「学了一个不该信的名字」,而两种都不会报错。
        """
        answering = {provider for provider in CONNECTORS
                     if official_profile_handle(provider, "some-name")}
        self.assertEqual(answering, set(_OFFICIAL_IDENTITY_PROVIDERS))


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