File size: 12,226 Bytes
9e11583
d6082ad
9e11583
d6082ad
 
 
9e11583
 
 
 
 
d6082ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e11583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
"""覆盖 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