"""登录态会话测试: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"買い物かご {auth_site.RAKUTEN_LOGGED_OUT_MARKER}" CART_LOGGED_IN = "買い物かご 商品が1点入っています" MYPAGE_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="ログイン") 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()