AutoTeam-F / tests /unit /test_cmd_check_standby.py
ZRainbow's picture
fix(invite-hardening): round-2 实际修复落地 — invite/reconcile/standby/UI
9e11583
Raw
History Blame Contribute Delete
12.2 kB
"""覆盖 cmd_check 新增 include_standby 开关 + _probe_standby_quota。
原 task #2:
- include_standby=False(默认) 不探测 standby 池,保持向后兼容
- include_standby=True 调用 _probe_standby_quota,遍历 standby + 限速 + 24h 去重
- 401/403 类 auth_error → STATUS_AUTH_INVALID
task #3 修复回归:
- network_error 不写 last_quota_check_at(允许下一轮重试),不改 status,只 log
- 未知 status 防御分支不写时间戳,避免去重逻辑卡住未来探测
- check_codex_quota 仅 401/403 才返回 auth_error;5xx/429/超时/SSL 错误归 network_error
"""
from __future__ import annotations
from autoteam import manager
from autoteam.accounts import STATUS_ACTIVE, STATUS_AUTH_INVALID, STATUS_STANDBY
def _stub_cmd_check_deps(monkeypatch, accounts_list):
"""把 cmd_check 走通但所有外部副作用短路,仅观察 _probe_standby_quota 是否被调用。
配合 accounts_list 至少包含一个 auth_file 存在的 active 账号,避免 "没有可检查的 active"
提前 return。
"""
monkeypatch.setattr(manager, "load_accounts", lambda: accounts_list)
monkeypatch.setattr(manager, "_reconcile_team_members", lambda *_a, **_kw: {})
monkeypatch.setattr(manager, "_check_and_refresh", lambda acc: ("ok", {"primary_pct": 10, "weekly_pct": 10}))
monkeypatch.setattr(manager, "update_account", lambda *_a, **_kw: None)
monkeypatch.setattr(manager, "sync_to_cpa", lambda: None)
# 屏蔽 personal 分支中 load_accounts 再调(上面已 monkeypatch 生效)
# CLOUDMAIL_DOMAIN 走 config import,无需额外 mock
def _fake_auth_file(tmp_path, email):
f = tmp_path / f"codex-{email}.json"
f.write_text("{}", encoding="utf-8")
return str(f)
def test_check_skips_standby_by_default(tmp_path, monkeypatch):
"""cmd_check() 不传 include_standby → 默认 False → 不应调用 _probe_standby_quota。"""
probe_called = {"n": 0}
monkeypatch.setattr(manager, "_probe_standby_quota", lambda: probe_called.__setitem__("n", probe_called["n"] + 1))
active = {
"email": "a@example.com",
"status": STATUS_ACTIVE,
"auth_file": _fake_auth_file(tmp_path, "a@example.com"),
}
_stub_cmd_check_deps(monkeypatch, [active])
manager.cmd_check() # 默认 include_standby=False
assert probe_called["n"] == 0
def test_check_include_standby_probes_all(tmp_path, monkeypatch):
"""cmd_check(include_standby=True) 必须调用 _probe_standby_quota。"""
probe_called = {"n": 0}
monkeypatch.setattr(manager, "_probe_standby_quota", lambda: probe_called.__setitem__("n", probe_called["n"] + 1))
active = {
"email": "a@example.com",
"status": STATUS_ACTIVE,
"auth_file": _fake_auth_file(tmp_path, "a@example.com"),
}
_stub_cmd_check_deps(monkeypatch, [active])
manager.cmd_check(include_standby=True)
assert probe_called["n"] == 1
def test_check_rate_limited_between_accounts(tmp_path, monkeypatch):
"""_probe_standby_quota 相邻账号必须 sleep STANDBY_PROBE_INTERVAL_SEC,避免群访风控。"""
stby_a = {
"email": "s1@example.com",
"status": STATUS_STANDBY,
"auth_file": _fake_auth_file(tmp_path, "s1"),
"last_quota_check_at": None,
}
stby_b = {
"email": "s2@example.com",
"status": STATUS_STANDBY,
"auth_file": _fake_auth_file(tmp_path, "s2"),
"last_quota_check_at": None,
}
monkeypatch.setattr(manager, "get_standby_accounts", lambda: [stby_a, stby_b])
monkeypatch.setattr(manager, "_check_and_refresh", lambda acc: ("ok", {"primary_pct": 20, "weekly_pct": 20}))
monkeypatch.setattr(manager, "update_account", lambda *_a, **_kw: None)
sleeps = []
monkeypatch.setattr(manager.time, "sleep", lambda s: sleeps.append(s))
manager._probe_standby_quota()
# 2 账号之间应该 sleep 恰好 1 次(第一个前不 sleep),间隔 = STANDBY_PROBE_INTERVAL_SEC
assert sleeps == [manager.STANDBY_PROBE_INTERVAL_SEC]
def test_check_skips_recently_probed(tmp_path, monkeypatch):
"""last_quota_check_at 在 24h 内的 standby 必须被跳过,不再消耗 wham 配额。"""
now = 1_700_000_000.0
monkeypatch.setattr(manager.time, "time", lambda: now)
recent = {
"email": "recent@example.com",
"status": STATUS_STANDBY,
"auth_file": _fake_auth_file(tmp_path, "recent"),
"last_quota_check_at": now - 3600, # 1h 前探测过
}
stale = {
"email": "stale@example.com",
"status": STATUS_STANDBY,
"auth_file": _fake_auth_file(tmp_path, "stale"),
"last_quota_check_at": now - (manager.STANDBY_PROBE_DEDUP_SEC + 60), # 超过 24h
}
monkeypatch.setattr(manager, "get_standby_accounts", lambda: [recent, stale])
monkeypatch.setattr(manager.time, "sleep", lambda *_a: None)
probed = []
def fake_check_and_refresh(acc):
probed.append(acc["email"])
return ("ok", {"primary_pct": 50, "weekly_pct": 50})
monkeypatch.setattr(manager, "_check_and_refresh", fake_check_and_refresh)
monkeypatch.setattr(manager, "update_account", lambda *_a, **_kw: None)
manager._probe_standby_quota()
# recent 被 24h 去重跳过,只有 stale 被实际探测
assert probed == ["stale@example.com"]
def test_check_marks_auth_invalid_on_401(tmp_path, monkeypatch):
"""_check_and_refresh 返回 auth_error(401/403/token 刷新失败) → 标 STATUS_AUTH_INVALID。"""
stby = {
"email": "dead@example.com",
"status": STATUS_STANDBY,
"auth_file": _fake_auth_file(tmp_path, "dead"),
"last_quota_check_at": None,
}
monkeypatch.setattr(manager, "get_standby_accounts", lambda: [stby])
monkeypatch.setattr(manager, "_check_and_refresh", lambda acc: ("auth_error", None))
monkeypatch.setattr(manager.time, "sleep", lambda *_a: None)
updates = []
monkeypatch.setattr(manager, "update_account", lambda email, **kw: updates.append((email, kw)))
manager._probe_standby_quota()
assert len(updates) == 1
email, fields = updates[0]
assert email == "dead@example.com"
assert fields["status"] == STATUS_AUTH_INVALID
assert "last_quota_check_at" in fields
# ---------------------------------------------------------------------------
# task #3 回归保护:network_error 必须不动 status / 不写时间戳
# ---------------------------------------------------------------------------
def test_probe_network_error_keeps_status_unchanged(tmp_path, monkeypatch):
"""5xx/timeout/SSL 异常 → status_str="network_error" → **不写** last_quota_check_at,
**不改** status。避免一次网络抖动让整批 standby 在 24h 内不再被探测。"""
stby = {
"email": "flaky@example.com",
"status": STATUS_STANDBY,
"auth_file": _fake_auth_file(tmp_path, "flaky"),
"last_quota_check_at": None,
}
monkeypatch.setattr(manager, "get_standby_accounts", lambda: [stby])
monkeypatch.setattr(manager, "_check_and_refresh", lambda acc: ("network_error", None))
monkeypatch.setattr(manager.time, "sleep", lambda *_a: None)
updates = []
monkeypatch.setattr(manager, "update_account", lambda email, **kw: updates.append((email, kw)))
manager._probe_standby_quota()
# network_error 分支必须既不调 update_account(不改 status),也不写时间戳
assert updates == [], f"network_error 不应触发 update_account,但收到: {updates}"
def test_probe_unknown_status_does_not_write_timestamp(tmp_path, monkeypatch):
"""未知 status_str → 防御分支不写 last_quota_check_at,避免 24h 去重屏蔽未来真实探测。"""
stby = {
"email": "weird@example.com",
"status": STATUS_STANDBY,
"auth_file": _fake_auth_file(tmp_path, "weird"),
"last_quota_check_at": None,
}
monkeypatch.setattr(manager, "get_standby_accounts", lambda: [stby])
monkeypatch.setattr(manager, "_check_and_refresh", lambda acc: ("future_unknown_status", None))
monkeypatch.setattr(manager.time, "sleep", lambda *_a: None)
updates = []
monkeypatch.setattr(manager, "update_account", lambda email, **kw: updates.append((email, kw)))
manager._probe_standby_quota()
assert updates == [], f"未知 status 不应触发 update_account,但收到: {updates}"
def test_probe_auth_error_only_for_401_403(monkeypatch):
"""check_codex_quota 必须严格区分:
- 401/403 → auth_error
- 5xx / 429 / 4xx其他 → network_error
- timeout / SSL / Connection 异常 → network_error
- 200 但 JSON 解析失败 → network_error
- 200 + 正常 payload → ok
"""
import requests
from autoteam import codex_auth
# 关闭外部依赖:account_id 探测
monkeypatch.setattr(codex_auth, "get_chatgpt_account_id", lambda: None)
class FakeResp:
def __init__(self, status_code, payload=None, raise_json=False, text=""):
self.status_code = status_code
self._payload = payload
self._raise_json = raise_json
self.text = text
def json(self):
if self._raise_json:
raise ValueError("not json")
return self._payload
def make_get(resp_or_exc):
def fake_get(*_a, **_kw):
if isinstance(resp_or_exc, Exception):
raise resp_or_exc
return resp_or_exc
return fake_get
# check_codex_quota 内部用 `import requests` 后调 requests.get,
# 函数内的 import 会查 sys.modules['requests'],因此在 requests 模块上 monkeypatch.get
# 即可拦截调用。
# --- 401 → auth_error
monkeypatch.setattr(requests, "get", make_get(FakeResp(401)))
assert codex_auth.check_codex_quota("tok")[0] == "auth_error"
# --- 403 → auth_error
monkeypatch.setattr(requests, "get", make_get(FakeResp(403)))
assert codex_auth.check_codex_quota("tok")[0] == "auth_error"
# --- 500 → network_error(关键回归:以前会被误判 auth_error)
monkeypatch.setattr(requests, "get", make_get(FakeResp(500, text="upstream down")))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- 502 / 503 / 504 → network_error
for code in (502, 503, 504):
monkeypatch.setattr(requests, "get", make_get(FakeResp(code)))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- 429 → network_error(限流是临时性的)
monkeypatch.setattr(requests, "get", make_get(FakeResp(429)))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- 418 (其他 4xx,非 401/403/429) → network_error(保守归类)
monkeypatch.setattr(requests, "get", make_get(FakeResp(418)))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- ConnectionError → network_error
monkeypatch.setattr(requests, "get", make_get(requests.exceptions.ConnectionError("dns boom")))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- Timeout → network_error
monkeypatch.setattr(requests, "get", make_get(requests.exceptions.Timeout("slow")))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- SSLError → network_error
monkeypatch.setattr(requests, "get", make_get(requests.exceptions.SSLError("ssl fail")))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- 200 但 JSON 解析失败 → network_error(以前会被误判 auth_error)
monkeypatch.setattr(requests, "get", make_get(FakeResp(200, raise_json=True)))
assert codex_auth.check_codex_quota("tok")[0] == "network_error"
# --- 200 + 健康 payload → ok
healthy = {
"rate_limit": {
"primary_window": {"used_percent": 10, "reset_at": 0},
"secondary_window": {"used_percent": 5, "reset_at": 0},
"limit_reached": False,
}
}
monkeypatch.setattr(requests, "get", make_get(FakeResp(200, payload=healthy)))
status, info = codex_auth.check_codex_quota("tok")
assert status == "ok"
assert isinstance(info, dict)
assert info["primary_pct"] == 10