Files
surugaya/tests/test_warm_context.py
T

125 lines
3.9 KiB
Python

"""保活上下文(cf_clearance 复用)逻辑的单元测试(不启动真实 Chromium)。"""
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from app.core.config import Settings
from app.services.cloudflare_session import CloudflareSessionManager
from app.services.session_store import SessionStore
_DETAIL_URL = "https://www.suruga-ya.jp/product/detail/{}"
class _FakePage:
"""最小页面替身:goto 状态由所属上下文是否被标记失败决定。"""
def __init__(self, pool: "_FakeBrowserPool", context: "_FakeContext"):
self._pool = pool
self._context = context
async def goto(self, url: str, **_kw):
status = 403 if self._context.idx in self._pool.fail_idxs else 200
return SimpleNamespace(status=status)
async def title(self) -> str:
return "ok"
async def content(self) -> str:
return self._pool.content_html
async def screenshot(self, **_kw): # pragma: no cover - 非 200 分支才用到
return None
async def wait_for_timeout(self, _ms): # pragma: no cover
return None
class _FakeContext:
def __init__(self, idx: int):
self.idx = idx
self.closed = False
async def cookies(self):
return [{"name": "cf_clearance", "value": f"cv-{self.idx}"}]
async def close(self):
self.closed = True
class _FakeBrowserPool:
"""记录上下文创建次数,并允许把指定上下文标记为「复用必失败」。"""
def __init__(self):
self.content_html = "<html>ok</html>"
self.create_calls = 0
self.contexts: list[_FakeContext] = []
self.fail_idxs: set[int] = set()
async def create_persistent_context(self, storage_state=None) -> _FakeContext:
self.create_calls += 1
ctx = _FakeContext(self.create_calls)
self.contexts.append(ctx)
return ctx
@asynccontextmanager
async def page_in_context(self, context: _FakeContext):
yield _FakePage(self, context)
async def close_context(self, context: _FakeContext):
await context.close()
def _make_manager(pool: _FakeBrowserPool) -> CloudflareSessionManager:
settings = Settings(session_ttl_seconds=900, cloudflare_reuse_wait_timeout_seconds=8.0)
return CloudflareSessionManager(settings, pool, SessionStore(settings))
@pytest.mark.asyncio
async def test_warm_context_is_reused_without_rebuild():
pool = _FakeBrowserPool()
mgr = _make_manager(pool)
html1, sess1 = await mgr.fetch_html(_DETAIL_URL.format(1))
assert html1 == pool.content_html
assert pool.create_calls == 1 # 首次构建
# 第二次请求(不同商品)应复用同一保活上下文,不再新建
html2, sess2 = await mgr.fetch_html(_DETAIL_URL.format(2))
assert html2 == pool.content_html
assert pool.create_calls == 1
assert sess2.session_id == sess1.session_id
@pytest.mark.asyncio
async def test_warm_context_rebuilds_and_closes_on_failure():
pool = _FakeBrowserPool()
mgr = _make_manager(pool)
_, sess1 = await mgr.fetch_html(_DETAIL_URL.format(1))
assert pool.create_calls == 1
# 让首个上下文在后续复用导航时返回 403(模拟 clearance 失效)
pool.fail_idxs.add(1)
html2, sess2 = await mgr.fetch_html(_DETAIL_URL.format(2))
assert html2 == pool.content_html
assert pool.create_calls == 2 # 复用失败后重建
assert pool.contexts[0].closed is True # 旧上下文已退役关闭
assert sess2.session_id != sess1.session_id # 换了新会话
@pytest.mark.asyncio
async def test_close_releases_all_warm_contexts():
pool = _FakeBrowserPool()
mgr = _make_manager(pool)
await mgr.fetch_html(_DETAIL_URL.format(1))
assert pool.contexts[0].closed is False
await mgr.close()
assert pool.contexts[0].closed is True
assert mgr._warm_contexts == {}