账号
This commit is contained in:
+184
-3
@@ -1,10 +1,11 @@
|
||||
"""登录态会话测试:cookie 加载、失效探测、重新加载
|
||||
"""登录态会话测试:cookie 加载、失效探测、重新加载、自动重登
|
||||
|
||||
全部用 httpx.MockTransport 拦截,不触达真实站点、不需要真实账号。
|
||||
登录态文件写在 tmp_path 下,不碰仓库里的 .auth/。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -14,6 +15,7 @@ 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 import auth_session as auth_session_mod
|
||||
from app.trading.services.auth_session import AuthSession
|
||||
|
||||
# 购物车页的两种形态:含未登录标记 = 未登录,不含 = 已登录
|
||||
@@ -200,11 +202,11 @@ async def test_network_error_raises_upstream(tmp_path):
|
||||
|
||||
|
||||
async def test_require_logged_in_raises_when_logged_out(tmp_path):
|
||||
"""未登录时 require_logged_in 抛 5001,且标记为不可重试
|
||||
"""relogin_enabled=False 时,未登录直接抛 5001,且标记为不可重试
|
||||
|
||||
登录需要人工过验证码,自动重试没有意义,必须让上游停下来。
|
||||
"""
|
||||
settings = make_settings(tmp_path)
|
||||
settings = make_settings(tmp_path, relogin_enabled=False)
|
||||
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
||||
try:
|
||||
with pytest.raises(NotLoggedInError) as excinfo:
|
||||
@@ -227,6 +229,185 @@ async def test_require_logged_in_passes_when_logged_in(tmp_path):
|
||||
await session.close()
|
||||
|
||||
|
||||
# ---- 自动重登 ----
|
||||
|
||||
|
||||
def _patch_relogin(monkeypatch, *, return_value: bool, sleep_quick: bool = True):
|
||||
"""把 login_runner.login_one 替换成可控桩
|
||||
|
||||
login_one 在 AuthSession.try_relogin 里通过 from import 触发,所以 patch
|
||||
的对象要是 login_runner 模块上的 login_one。
|
||||
"""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_login_one(account, settings, *, timeout_seconds=..., progress=None):
|
||||
calls.append({
|
||||
"site": account.site,
|
||||
"account_id": account.id,
|
||||
"username": account.username, # 仅测试中校验不泄密给日志
|
||||
"timeout_seconds": timeout_seconds,
|
||||
})
|
||||
if sleep_quick:
|
||||
await asyncio.sleep(0) # 让协程调度
|
||||
return return_value
|
||||
|
||||
# AuthSession.try_relogin 内部用 `from ... import login_runner` 形式
|
||||
from app.trading.services import login_runner
|
||||
monkeypatch.setattr(login_runner, "login_one", fake_login_one)
|
||||
return calls
|
||||
|
||||
|
||||
def _patch_accounts_file(monkeypatch, tmp_path, *, has_rakuten: bool = True):
|
||||
"""让 login_runner 读到一份假的 account.yaml"""
|
||||
accounts = {"rakuten": []} if has_rakuten else {}
|
||||
if has_rakuten:
|
||||
accounts["rakuten"].append({
|
||||
"id": "testacct",
|
||||
"username": "u@example.com",
|
||||
"password": "pw",
|
||||
"user_data_dir": str(tmp_path / "ud"),
|
||||
"state_filename": "rakuten_state.json",
|
||||
"default": True,
|
||||
})
|
||||
yaml_text = "".join(
|
||||
f"{site}:\n" + "".join(
|
||||
f" - id: {r['id']}\n username: {r['username']}\n password: {r['password']}\n"
|
||||
f" user_data_dir: {r['user_data_dir']}\n state_filename: {r['state_filename']}\n"
|
||||
f" default: true\n"
|
||||
for r in recs
|
||||
)
|
||||
for site, recs in accounts.items()
|
||||
)
|
||||
from app.trading.services import login_runner
|
||||
fake_path = tmp_path / "account.yaml"
|
||||
fake_path.write_text(yaml_text, encoding="utf-8")
|
||||
monkeypatch.setattr(login_runner, "accounts_file_path", lambda: fake_path)
|
||||
|
||||
|
||||
async def test_require_logged_in_tries_relogin_and_passes(tmp_path, monkeypatch):
|
||||
"""relogin_enabled=True:失效 → login_one 成功 → check 通过 → 放行
|
||||
|
||||
login_one 模拟重登成功:落盘一份新 storage_state,并把 MockTransport 切到
|
||||
「已登录」分支,让 require_logged_in 第二次 check 通过。
|
||||
"""
|
||||
_patch_accounts_file(monkeypatch, tmp_path)
|
||||
|
||||
state = {"logged_in": False}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if state["logged_in"]:
|
||||
return httpx.Response(200, text=CART_LOGGED_IN)
|
||||
return httpx.Response(200, text=CART_LOGGED_OUT)
|
||||
|
||||
settings = make_settings(tmp_path, relogin_enabled=True, relogin_timeout_seconds=5)
|
||||
session = await build_session(settings, handler)
|
||||
|
||||
from app.trading.services import login_runner
|
||||
|
||||
async def fake_login_one(account, s, *, timeout_seconds=300, progress=None):
|
||||
# 模拟重登成功:落盘新 storage_state(让 reload 能读到),翻转 check 行为
|
||||
write_state(
|
||||
settings,
|
||||
"rakuten",
|
||||
[{"name": "FRESH", "value": "xyz", "domain": ".rakuten.co.jp", "path": "/"}],
|
||||
)
|
||||
state["logged_in"] = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(login_runner, "login_one", fake_login_one)
|
||||
|
||||
try:
|
||||
await session.require_logged_in("rakuten") # 不应抛出
|
||||
assert session.status("rakuten").logged_in is True
|
||||
# 重登后的 cookie 已灌进 client
|
||||
names = {c.name for c in session.client("rakuten").cookies.jar}
|
||||
assert "FRESH" in names
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def test_require_logged_in_raises_when_relogin_fails(tmp_path, monkeypatch):
|
||||
"""relogin_enabled=True 但 login_one 失败 → 抛 NotLoggedInError"""
|
||||
_patch_accounts_file(monkeypatch, tmp_path)
|
||||
_patch_relogin(monkeypatch, return_value=False)
|
||||
|
||||
settings = make_settings(tmp_path, relogin_enabled=True, relogin_timeout_seconds=5)
|
||||
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
||||
try:
|
||||
with pytest.raises(NotLoggedInError):
|
||||
await session.require_logged_in("rakuten")
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def test_require_logged_in_falls_back_when_no_accounts_file(tmp_path, monkeypatch):
|
||||
"""relogin_enabled=True 但 account.yaml 不存在 → try_relogin 返回 False,抛 NotLoggedInError"""
|
||||
from app.trading.services import login_runner
|
||||
monkeypatch.setattr(login_runner, "accounts_file_path", lambda: tmp_path / "missing.yaml")
|
||||
|
||||
settings = make_settings(tmp_path, relogin_enabled=True)
|
||||
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
||||
try:
|
||||
with pytest.raises(NotLoggedInError):
|
||||
await session.require_logged_in("rakuten")
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def test_try_relogin_is_disabled_when_flag_off(tmp_path):
|
||||
"""relogin_enabled=False:try_relogin 直接返回 False,不读 account.yaml"""
|
||||
settings = make_settings(tmp_path, relogin_enabled=False)
|
||||
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
||||
try:
|
||||
ok = await session.try_relogin("rakuten")
|
||||
assert ok is False
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def test_try_relogin_serializes_concurrent_calls_same_site(tmp_path, monkeypatch):
|
||||
"""同 site 并发触发只跑一次 login_one:靠 site 级锁串行化"""
|
||||
_patch_accounts_file(monkeypatch, tmp_path)
|
||||
|
||||
invocations = {"count": 0, "in_flight_max": 0, "current": 0}
|
||||
from app.trading.services import login_runner
|
||||
|
||||
async def counting_login_one(account, s, *, timeout_seconds=300, progress=None):
|
||||
invocations["current"] += 1
|
||||
invocations["in_flight_max"] = max(invocations["in_flight_max"], invocations["current"])
|
||||
await asyncio.sleep(0.05) # 故意拉长,让并发请求有机会叠上来
|
||||
invocations["current"] -= 1
|
||||
invocations["count"] += 1
|
||||
# 落盘新 storage_state,让 reload 有东西可读
|
||||
write_state(
|
||||
make_settings(tmp_path), # 用同一路径
|
||||
"rakuten",
|
||||
[{"name": "X", "value": "1", "domain": ".rakuten.co.jp", "path": "/"}],
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(login_runner, "login_one", counting_login_one)
|
||||
|
||||
settings = make_settings(tmp_path, relogin_enabled=True, relogin_timeout_seconds=10)
|
||||
session = await build_session(settings, lambda r: httpx.Response(200, text=CART_LOGGED_OUT))
|
||||
|
||||
try:
|
||||
# 第一次状态为 logged_in=None,三个并发都进入 try_relogin
|
||||
results = await asyncio.gather(
|
||||
session.try_relogin("rakuten"),
|
||||
session.try_relogin("rakuten"),
|
||||
session.try_relogin("rakuten"),
|
||||
)
|
||||
# login_one 至少被调一次(串行下后续可能因 status 已 logged_in 跳过)
|
||||
assert invocations["count"] >= 1
|
||||
# 关键:login_one 永远没并发执行
|
||||
assert invocations["in_flight_max"] == 1
|
||||
# 结果都成功(要么真重登,要么拿到锁后发现已 logged_in)
|
||||
assert all(results)
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# ---- 重新加载 ----
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user