把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 N 份登录态与 N 个轮询, 同一账号会被并发操作。 - app/shared:配置、错误码、日志、ApiResponse 信封 + Bearer 鉴权 + 异常处理器、 导航请求头构造器 - app/scraping:站点常量、会话、解析器与 10 个抓取接口,:31107,可多开 - app/trading:登录态查询/重载与健康检查,:31108,只能单实例 - 依赖方向锁为 scraping→shared、trading→shared,两侧互不 import; tests/test_architecture.py 用 AST 检查 import 并校验两个 app 的路径不串 - 登录态 UA 在 trading 独立持有:与抓取 UA 值相同但变更理由不同,抓取 UA 为绕 反爬可随时调整,登录 UA 一改可能触发设备校验使已落盘 cookie 失效 - scripts/login.py 与 AuthSession 共用 auth_site.PROFILES 与 is_logged_in,判据只写一遍 - 同一镜像两个启动命令,交易容器覆盖 command 并设 RAKUTEN_HEALTH_PORT 同时带上此前未提交的 ラクマ 分类接口与登录态基础设施。 验证:239 个离线用例全绿;两个入口真实启动,/health 与鉴权正常。 未验证:真实探测登录态(当前开发机无外网,对站点的连接全部超时)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
265 lines
9.3 KiB
Python
265 lines
9.3 KiB
Python
"""登录态会话测试:cookie 加载、失效探测、重新加载
|
|
|
|
全部用 httpx.MockTransport 拦截,不触达真实站点、不需要真实账号。
|
|
登录态文件写在 tmp_path 下,不碰仓库里的 .auth/。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from app.shared.config import Settings
|
|
from app.shared.errors import NotLoggedInError, UpstreamRequestError
|
|
from app.trading.core import auth_site
|
|
from app.trading.services.auth_session import AuthSession
|
|
|
|
# 购物车页的两种形态:含未登录标记 = 未登录,不含 = 已登录
|
|
CART_LOGGED_OUT = f"<html><body>買い物かご {auth_site.RAKUTEN_LOGGED_OUT_MARKER}</body></html>"
|
|
CART_LOGGED_IN = "<html><body>買い物かご 商品が1点入っています</body></html>"
|
|
|
|
MYPAGE_HTML = "<html><body>マイページ</body></html>"
|
|
|
|
|
|
def make_settings(tmp_path: Path, **overrides) -> Settings:
|
|
base = {
|
|
"auth_state_dir": str(tmp_path / "auth"),
|
|
"request_timeout_seconds": 5.0,
|
|
}
|
|
base.update(overrides)
|
|
return Settings(**base)
|
|
|
|
|
|
def write_state(settings: Settings, site: str, cookies: list[dict]) -> Path:
|
|
"""伪造一份 Playwright storage_state 落盘"""
|
|
path = settings.auth_state_path / auth_site.profile(site).state_filename
|
|
path.write_text(
|
|
json.dumps({"cookies": cookies, "origins": []}, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
async def build_session(settings: Settings, handler) -> AuthSession:
|
|
"""构建 AuthSession 并把两站 client 换成 MockTransport 版本
|
|
|
|
换掉 client 会丢掉 start() 时灌进去的 cookie,因此重新走一次 reload
|
|
把登录态读回新客户端;reload 同时清空探测缓存,正好是测试想要的干净起点。
|
|
"""
|
|
session = AuthSession(settings)
|
|
await session.start()
|
|
for name in session.sites:
|
|
auth = session._sites[name]
|
|
await auth.client.aclose()
|
|
auth.client = httpx.AsyncClient(
|
|
transport=httpx.MockTransport(handler),
|
|
follow_redirects=True,
|
|
)
|
|
session.reload(name)
|
|
return session
|
|
|
|
|
|
# ---- cookie 加载 ----
|
|
|
|
|
|
async def test_loads_cookies_from_state_file(tmp_path):
|
|
"""storage_state 里的 cookie 应被灌进 httpx 客户端"""
|
|
settings = make_settings(tmp_path)
|
|
write_state(
|
|
settings,
|
|
"rakuten",
|
|
[{"name": "SESSION", "value": "abc", "domain": ".rakuten.co.jp", "path": "/"}],
|
|
)
|
|
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_IN))
|
|
try:
|
|
names = {c.name for c in session.client("rakuten").cookies.jar}
|
|
assert "SESSION" in names
|
|
# 没有 state 文件的那一站应为空,而不是报错
|
|
assert not list(session.client("rakuma").cookies.jar)
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_starts_without_state_file(tmp_path):
|
|
"""登录态文件不存在时仍能启动,只是报告未登录——服务不应因此起不来"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
|
try:
|
|
status = session.status("rakuten")
|
|
assert status.state_file_exists is False
|
|
assert status.logged_in is None # 尚未探测
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_corrupted_state_file_is_tolerated(tmp_path):
|
|
"""登录态文件损坏时降级为无 cookie,不抛异常"""
|
|
settings = make_settings(tmp_path)
|
|
path = settings.auth_state_path / auth_site.profile("rakuten").state_filename
|
|
path.write_text("{ not json", encoding="utf-8")
|
|
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
|
try:
|
|
assert not list(session.client("rakuten").cookies.jar)
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
# ---- 登录态探测 ----
|
|
|
|
|
|
async def test_rakuten_detects_logged_out_by_marker(tmp_path):
|
|
"""购物车页出现未登录文案即判定未登录"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
|
try:
|
|
status = await session.check("rakuten")
|
|
assert status.logged_in is False
|
|
assert "未登录" in status.detail
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_rakuten_detects_logged_in(tmp_path):
|
|
"""购物车页没有未登录文案即判定已登录"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_IN))
|
|
try:
|
|
status = await session.check("rakuten")
|
|
assert status.logged_in is True
|
|
assert status.checked_at is not None
|
|
# 序列化后暴露给 API 的是相对时长,不是单调时钟原值
|
|
assert status.to_dict()["checked_age_seconds"] is not None
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_rakuma_detects_logged_out_by_redirect(tmp_path):
|
|
"""/mypage 被重定向到登录页即判定未登录
|
|
|
|
ラクマ 未登录时返回 302 而非改文案,因此判据是落地 URL 不是页面内容。
|
|
"""
|
|
settings = make_settings(tmp_path)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path == "/mypage":
|
|
return httpx.Response(302, headers={"Location": auth_site.RAKUMA_LOGIN_URL})
|
|
return httpx.Response(200, text="<html>ログイン</html>")
|
|
|
|
session = await build_session(settings, handler)
|
|
try:
|
|
status = await session.check("rakuma")
|
|
assert status.logged_in is False
|
|
assert "登录页" in status.detail
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_rakuma_detects_logged_in(tmp_path):
|
|
"""/mypage 正常返回即判定已登录"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=MYPAGE_HTML))
|
|
try:
|
|
status = await session.check("rakuma")
|
|
assert status.logged_in is True
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_error_status_counts_as_logged_out(tmp_path):
|
|
"""探测页返回 4xx/5xx 时保守判定为未登录,不放行下单"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(503, text="oops"))
|
|
try:
|
|
status = await session.check("rakuten")
|
|
assert status.logged_in is False
|
|
assert "503" in status.detail
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_network_error_raises_upstream(tmp_path):
|
|
"""网络异常与「确实未登录」是两回事,应抛错而不是静默判未登录"""
|
|
settings = make_settings(tmp_path)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("boom")
|
|
|
|
session = await build_session(settings, handler)
|
|
try:
|
|
with pytest.raises(UpstreamRequestError):
|
|
await session.check("rakuten")
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
# ---- 下单前置校验 ----
|
|
|
|
|
|
async def test_require_logged_in_raises_when_logged_out(tmp_path):
|
|
"""未登录时 require_logged_in 抛 5001,且标记为不可重试
|
|
|
|
登录需要人工过验证码,自动重试没有意义,必须让上游停下来。
|
|
"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
|
try:
|
|
with pytest.raises(NotLoggedInError) as excinfo:
|
|
await session.require_logged_in("rakuten")
|
|
assert excinfo.value.err_code == 5001
|
|
assert excinfo.value.retryable is False
|
|
assert excinfo.value.status_code == 401
|
|
assert "scripts/login.py" in excinfo.value.message
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_require_logged_in_passes_when_logged_in(tmp_path):
|
|
"""已登录时放行,不抛异常"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_IN))
|
|
try:
|
|
await session.require_logged_in("rakuten") # 不应抛出
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
# ---- 重新加载 ----
|
|
|
|
|
|
async def test_reload_picks_up_new_cookies(tmp_path):
|
|
"""人工重新登录后 reload 应换上新 cookie 并清掉旧的探测结论"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_IN))
|
|
try:
|
|
await session.check("rakuten")
|
|
assert session.status("rakuten").logged_in is True
|
|
|
|
write_state(
|
|
settings,
|
|
"rakuten",
|
|
[{"name": "FRESH", "value": "xyz", "domain": ".rakuten.co.jp", "path": "/"}],
|
|
)
|
|
count = session.reload("rakuten")
|
|
|
|
assert count == 1
|
|
assert "FRESH" in {c.name for c in session.client("rakuten").cookies.jar}
|
|
# 重载后旧结论必须作废,避免拿过期判断放行下单
|
|
assert session.status("rakuten").logged_in is None
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
async def test_unknown_site_rejected(tmp_path):
|
|
"""未知站点名应明确报错,不静默返回空状态"""
|
|
settings = make_settings(tmp_path)
|
|
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_IN))
|
|
try:
|
|
with pytest.raises(ValueError):
|
|
session.status("mercari")
|
|
finally:
|
|
await session.close()
|