AutoTeam-F / tests /unit /test_api_status.py
ZRainbow's picture
fix: align protected seat rotation semantics
25aa943
Raw
History Blame Contribute Delete
41.6 kB
import json
import logging
import threading
import time
from autoteam import accounts, api, config
def test_get_status_normalizes_main_account_status_from_saved_auth(tmp_path, monkeypatch):
main_email = "owner@example.com"
auth_file = tmp_path / "codex-main.json"
auth_file.write_text(json.dumps({"access_token": "token-main"}), encoding="utf-8")
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [
{
"email": main_email,
"status": "exhausted",
"auth_file": "/app/auths/codex-main.json",
"last_quota": {
"primary_pct": 8,
"primary_resets_at": 1710000000,
"weekly_pct": 1,
"weekly_resets_at": 1710600000,
},
}
],
)
monkeypatch.setattr(api, "_is_main_account_email", lambda email: email == main_email)
monkeypatch.setattr("autoteam.codex_auth.get_saved_main_auth_file", lambda: str(auth_file))
monkeypatch.setattr(
"autoteam.codex_auth.check_codex_quota",
lambda access_token: (
"ok",
{
"primary_pct": 8,
"primary_resets_at": 1710000000,
"weekly_pct": 1,
"weekly_resets_at": 1710600000,
},
),
)
result = api.get_status()
assert result["quota_cache"][main_email]["primary_pct"] == 8
assert result["accounts"][0]["is_main_account"] is True
assert result["accounts"][0]["status"] == "active"
assert result["summary"] == {
"active": 1,
"standby": 0,
"exhausted": 0,
"pending": 0,
"personal": 0,
"auth_invalid": 0,
"orphan": 0,
"disabled": 0,
"total": 1,
}
def test_get_status_survives_runtime_resource_probe_failure(monkeypatch):
monkeypatch.setattr("autoteam.accounts.load_accounts", lambda: [])
monkeypatch.setattr(
api,
"collect_runtime_resource_snapshot",
lambda: (_ for _ in ()).throw(RuntimeError("proc unavailable")),
)
result = api.get_status()
assert result["accounts"] == []
assert result["runtime_resources"] == {"error": "runtime_resource_snapshot_failed"}
def test_get_status_includes_ipv6_pool_status(monkeypatch):
monkeypatch.setattr("autoteam.accounts.load_accounts", lambda: [])
monkeypatch.setattr(
"autoteam.ipv6_pool.ipv6_pool.status",
lambda: {"enabled": True, "required": False, "ok": True, "count": 2, "entries": []},
)
result = api.get_status()
assert result["ipv6_pool"]["enabled"] is True
assert result["ipv6_pool"]["count"] == 2
def test_get_status_exposes_clipproxy_and_rotation_validation(tmp_path, monkeypatch):
auth_file = tmp_path / "codex-child.json"
auth_file.write_text(json.dumps({"access_token": "token-child"}), encoding="utf-8")
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [{"email": "child@example.com", "status": accounts.STATUS_ACTIVE, "auth_file": str(auth_file)}],
)
monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", lambda *_args, **_kwargs: ("ok", {"primary_pct": 20}))
monkeypatch.setattr(
"autoteam.cliproxy_health.get_cliproxy_health",
lambda: {
"ok": False,
"safe_read_only": True,
"management_api": {"ok": True},
"provider_auth": {
"ok": False,
"provider": "codex",
"model": "gpt-5.5",
"reason": "no_provider_auth",
"total": 0,
"available": 0,
"check_type": "management_metadata",
"canary_required": True,
},
},
)
monkeypatch.setattr(
api,
"_rotation_validation_cooldown",
{"next_rotate_after": 0.0, "recorded_at": 123.0, "severity": "ok", "reason": ""},
)
result = api.get_status()
assert result["summary"]["active"] == 1
assert result["cliproxy"]["safe_read_only"] is True
assert result["cliproxy"]["provider_auth"]["reason"] == "no_provider_auth"
assert result["rotation_validation"]["severity"] == "ok"
assert result["rotation_validation"]["cooldown_remaining_seconds"] == 0
def test_get_status_fast_skips_live_quota_probe(tmp_path, monkeypatch):
auth_file = tmp_path / "codex-child.json"
auth_file.write_text(json.dumps({"access_token": "token-child"}), encoding="utf-8")
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [{"email": "child@example.com", "status": accounts.STATUS_ACTIVE, "auth_file": str(auth_file)}],
)
monkeypatch.setattr(
"autoteam.codex_auth.check_codex_quota",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("fast status must not probe quota")),
)
health_calls = []
monkeypatch.setattr(
"autoteam.cliproxy_health.get_cliproxy_health",
lambda **kwargs: health_calls.append(kwargs)
or {
"ok": True,
"safe_read_only": True,
"management_api": {"ok": True},
"provider_auth": {
"ok": True,
"provider": "codex",
"model": "gpt-5.5",
"reason": "provider_auth_has_candidates",
"total": 1,
"available": 1,
"check_type": "management_metadata",
"canary_required": True,
},
},
)
result = api.get_status(fast=True)
assert result["quota_cache"] == {}
assert result["status_mode"] == {"fast": True, "quota_budget_seconds": 0, "live_quota": False}
assert health_calls == [{"timeout": 0.5, "cache_ttl": 30.0}]
def test_get_playwright_context_options_uses_fingerprint_constants(monkeypatch):
monkeypatch.setattr(config, "PLAYWRIGHT_USER_AGENT", "AutoTeamTest/1.0")
monkeypatch.setattr(config, "PLAYWRIGHT_LOCALE", "zh-CN")
monkeypatch.setattr(config, "PLAYWRIGHT_TIMEZONE_ID", "Asia/Shanghai")
monkeypatch.setattr(config, "PLAYWRIGHT_VIEWPORT_WIDTH", 1440)
monkeypatch.setattr(config, "PLAYWRIGHT_VIEWPORT_HEIGHT", 900)
monkeypatch.setattr(config, "PLAYWRIGHT_DEVICE_SCALE_FACTOR", 2)
monkeypatch.setattr(config, "PLAYWRIGHT_COLOR_SCHEME", "light")
options = config.get_playwright_context_options()
assert options["viewport"] == {"width": 1440, "height": 900}
assert options["user_agent"] == "AutoTeamTest/1.0"
assert options["locale"] == "zh-CN"
assert options["timezone_id"] == "Asia/Shanghai"
assert options["device_scale_factor"] == 2
assert options["color_scheme"] == "light"
assert options["extra_http_headers"] == {"Accept-Language": "zh-CN,zh;q=0.9"}
def test_auto_check_cooldown_does_not_delay_real_team_shortage(tmp_path, monkeypatch):
auth_files = []
for idx in range(1):
auth_file = tmp_path / f"active-{idx}.json"
auth_file.write_text(json.dumps({"access_token": f"token-{idx}"}), encoding="utf-8")
auth_files.append(auth_file)
started = []
def fake_start_task(command, func, params, *args, **kwargs):
started.append((command, params, args, kwargs))
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", time.time())
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda _target: False)
monkeypatch.setattr(api, "_auto_check_team_member_count", lambda *args, **kwargs: 2)
monkeypatch.setattr(api, "_start_task", fake_start_task)
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [
{"email": f"active-{idx}@example.com", "status": "active", "auth_file": str(auth_files[idx])}
for idx in range(1)
],
)
stop_event = threading.Event()
restart_event = threading.Event()
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
api._auto_check_loop()
assert len(started) == 1
command, params, args, kwargs = started[0]
assert command == "auto-fill"
assert params == {"target_seats": 3}
assert args == (3,)
assert kwargs == {"background_post_sync": True}
def test_auto_check_cooldown_keeps_full_team_from_refilling(tmp_path, monkeypatch):
auth_files = []
for idx in range(1):
auth_file = tmp_path / f"active-{idx}.json"
auth_file.write_text(json.dumps({"access_token": f"token-{idx}"}), encoding="utf-8")
auth_files.append(auth_file)
started = []
probed = []
def fake_team_count(*args, **kwargs):
probed.append((args, kwargs))
return 3
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", time.time())
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda _target: False)
monkeypatch.setattr(api, "_auto_check_team_member_count", fake_team_count)
monkeypatch.setattr(api, "_start_task", lambda *args, **kwargs: started.append((args, kwargs)))
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [
{"email": f"active-{idx}@example.com", "status": "active", "auth_file": str(auth_files[idx])}
for idx in range(1)
],
)
monkeypatch.setattr(
"autoteam.codex_auth.check_codex_quota",
lambda _token: ("ok", {"primary_pct": 10, "primary_resets_at": 0, "weekly_pct": 1}),
)
stop_event = threading.Event()
restart_event = threading.Event()
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
api._auto_check_loop()
assert probed
assert started == []
def test_auto_check_team_member_count_carries_invites_and_occupancy(monkeypatch):
monkeypatch.setattr(api, "_run_playwright_probe", lambda *args, **kwargs: {"count": 3, "invites": 1, "occupancy": 4})
result = api._auto_check_team_member_count()
assert result == 3
assert api._team_member_invite_count(result) == 1
assert api._team_member_occupancy(result) == 4
def test_auto_check_skips_expensive_probe_when_task_is_running(monkeypatch, caplog):
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "_auto_check_stop", threading.Event())
monkeypatch.setattr(api, "_auto_check_restart", threading.Event())
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: (_ for _ in ()).throw(AssertionError("busy auto-check must not load accounts")),
)
monkeypatch.setattr(
"autoteam.cliproxy_health.get_cliproxy_health",
lambda **_kwargs: (_ for _ in ()).throw(AssertionError("busy auto-check must not query CPA gate")),
)
stop_event = api._auto_check_stop
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
api._playwright_lock.acquire()
try:
with caplog.at_level(logging.INFO):
api._auto_check_loop()
finally:
if api._playwright_lock.locked():
api._playwright_lock.release()
assert "跳过本轮自动巡检昂贵探测" in caplog.text
def test_auto_check_cooldown_allows_full_team_blocker_replacement(monkeypatch):
started = []
def fake_start_task(command, func, params, *args, **kwargs):
started.append((command, params, args, kwargs))
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", time.time())
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda _target: False)
monkeypatch.setattr(api, "_auto_check_team_member_count", lambda *args, **kwargs: 3)
monkeypatch.setattr(api, "_start_task", fake_start_task)
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [
{"email": "healthy@example.com", "status": "active", "auth_file": ""},
{"email": "blocked@example.com", "status": "auth_invalid", "auth_file": ""},
],
)
stop_event = threading.Event()
restart_event = threading.Event()
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
api._auto_check_loop()
assert len(started) == 1
command, params, args, kwargs = started[0]
assert command == "auto-fill"
assert params == {"target_seats": 3}
assert args == (3,)
assert kwargs == {"background_post_sync": True}
def test_auto_check_uses_read_only_cpa_gate_when_team_full_and_no_credentials(monkeypatch, caplog):
started = []
def fake_start_task(command, func, params, *args, **kwargs):
started.append((command, params, args, kwargs))
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", 0.0)
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr("autoteam.accounts.load_accounts", lambda: [])
monkeypatch.setattr(api, "_auto_check_team_member_count", lambda *args, **kwargs: 3)
monkeypatch.setattr(api, "_start_task", fake_start_task)
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda target: target == "cpa")
monkeypatch.setattr(
"autoteam.cliproxy_health.get_cliproxy_health",
lambda **_kwargs: {
"ok": False,
"safe_read_only": True,
"management_api": {"ok": True},
"provider_auth": {
"ok": False,
"provider": "codex",
"model": "gpt-5.5",
"reason": "no_provider_auth",
"total": 0,
"available": 0,
"check_type": "management_metadata",
"canary_required": True,
},
},
)
monkeypatch.setattr(
"autoteam.cpa_sync.sync_to_cpa",
lambda: (_ for _ in ()).throw(AssertionError("CPA gate must not sync")),
)
monkeypatch.setattr(
"autoteam.cpa_sync.upload_to_cpa",
lambda _path: (_ for _ in ()).throw(AssertionError("CPA gate must not upload")),
)
monkeypatch.setattr(
"autoteam.cpa_sync.delete_from_cpa",
lambda _name: (_ for _ in ()).throw(AssertionError("CPA gate must not delete")),
)
stop_event = threading.Event()
restart_event = threading.Event()
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
with caplog.at_level(logging.INFO):
api._auto_check_loop()
assert len(started) == 1
command, params, args, kwargs = started[0]
assert command == "auto-fill"
assert params == {
"target_seats": 3,
"trigger": "auto-check",
"shortage": 2,
"cpa_credential_gate": {
"enabled": True,
"safe_read_only": True,
"management_ok": True,
"available": 0,
"total": 0,
"reason": "no_provider_auth",
"zero_available": True,
},
"provider_auth_below_target": True,
"provider_auth_available": 0,
"provider_auth_target": 2,
}
assert args == (3,)
assert kwargs == {"background_post_sync": True}
assert "CPA 可用凭证 gate" in caplog.text
assert "CPA 可用凭证为 0/0" in caplog.text
def test_auto_check_triggers_cleanup_when_pending_invite_exceeds_occupancy(monkeypatch):
started = []
def fake_start_task(command, func, params, *args, **kwargs):
started.append((command, params, args, kwargs))
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", 0.0)
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr("autoteam.accounts.load_accounts", lambda: [])
monkeypatch.setattr(
api,
"_auto_check_team_member_count",
lambda *args, **kwargs: api._TeamMemberCount(3, invites=1, occupancy=4),
)
monkeypatch.setattr(api, "_start_task", fake_start_task)
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda target: False)
stop_event = threading.Event()
restart_event = threading.Event()
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
api._auto_check_loop()
assert len(started) == 1
command, params, args, kwargs = started[0]
assert command == "auto-cleanup"
assert params["max_seats"] == 3
assert params["trigger"] == "auto-check"
assert params["team_count"] == 3
assert params["invite_count"] == 1
assert params["team_occupancy"] == 4
assert args == (3,)
assert kwargs == {}
def test_auto_check_triggers_preventive_rotate_when_provider_auth_below_target(tmp_path, monkeypatch, caplog):
auth_ok = tmp_path / "active-ok.json"
auth_other = tmp_path / "active-other.json"
auth_ok.write_text(json.dumps({"access_token": "token-ok"}), encoding="utf-8")
auth_other.write_text(json.dumps({"access_token": "token-other"}), encoding="utf-8")
started = []
def fake_start_task(command, func, params, *args, **kwargs):
started.append((command, params, args, kwargs))
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", 0.0)
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [
{"email": "one@example.com", "status": "active", "auth_file": str(auth_ok)},
{"email": "two@example.com", "status": "active", "auth_file": str(auth_other)},
],
)
monkeypatch.setattr(
"autoteam.codex_auth.check_codex_quota",
lambda _token: ("ok", {"primary_pct": 10, "primary_resets_at": 1234567890, "weekly_pct": 1}),
)
monkeypatch.setattr(
api,
"_auto_check_team_member_count",
lambda *args, **kwargs: api._TeamMemberCount(3, invites=0, occupancy=3),
)
monkeypatch.setattr(api, "_start_task", fake_start_task)
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda target: target == "cpa")
monkeypatch.setattr(
"autoteam.cliproxy_health.get_cliproxy_health",
lambda **_kwargs: {
"ok": True,
"safe_read_only": True,
"management_api": {"ok": True},
"provider_auth": {
"ok": True,
"provider": "codex",
"model": "gpt-5.5",
"reason": "provider_auth_has_candidates",
"total": 4,
"available": 1,
"check_type": "management_metadata",
"canary_required": True,
},
},
)
stop_event = threading.Event()
restart_event = threading.Event()
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
with caplog.at_level(logging.INFO):
api._auto_check_loop()
assert len(started) == 1
command, params, args, kwargs = started[0]
assert command == "auto-rotate"
assert params["target"] == 3
assert params["trigger"] == "auto-check"
assert params["shortage"] == 1
assert params["low_accounts"] == 0
assert params["provider_auth_below_target"] is True
assert params["provider_auth_available"] == 1
assert params["provider_auth_target"] == 2
assert params["cpa_credential_gate"]["available"] == 1
assert args == (3,)
assert kwargs == {"background_post_sync": True}
assert "provider-auth 可用凭证低水位" in caplog.text
def test_auto_check_cpa_gate_does_not_treat_management_failure_as_zero_credentials(monkeypatch, caplog):
started = []
monkeypatch.setattr(api, "_auto_fill_last_trigger_ts", 0.0)
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "log_runtime_resource_snapshot", lambda *args, **kwargs: {})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr("autoteam.accounts.load_accounts", lambda: [])
monkeypatch.setattr(api, "_auto_check_team_member_count", lambda *args, **kwargs: 3)
monkeypatch.setattr(api, "_start_task", lambda *args, **kwargs: started.append((args, kwargs)))
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda target: target == "cpa")
monkeypatch.setattr(
"autoteam.cliproxy_health.get_cliproxy_health",
lambda **_kwargs: {
"ok": False,
"safe_read_only": True,
"management_api": {"ok": False, "reason": "request_failed"},
"provider_auth": {
"ok": False,
"provider": "codex",
"model": "gpt-5.5",
"reason": "management_api_unavailable",
"total": 0,
"available": 0,
"check_type": "management_metadata",
"canary_required": True,
},
},
)
stop_event = threading.Event()
restart_event = threading.Event()
wait_calls = {"count": 0}
def fake_wait(_seconds):
wait_calls["count"] += 1
return wait_calls["count"] > 1
monkeypatch.setattr(stop_event, "wait", fake_wait)
monkeypatch.setattr(api, "_auto_check_stop", stop_event)
monkeypatch.setattr(api, "_auto_check_restart", restart_event)
with caplog.at_level(logging.INFO):
api._auto_check_loop()
assert started == []
assert "CPA 可用凭证 gate" in caplog.text
assert "management_ok=False" in caplog.text
def test_collect_cpa_credential_gate_requires_safe_read_only_for_zero_available(monkeypatch):
monkeypatch.setattr("autoteam.sync_targets.is_sync_target_enabled", lambda target: target == "cpa")
monkeypatch.setattr(
"autoteam.cliproxy_health.get_cliproxy_health",
lambda **_kwargs: {
"ok": True,
"safe_read_only": False,
"management_api": {"ok": True},
"provider_auth": {
"ok": True,
"provider": "codex",
"model": "gpt-5.5",
"reason": "provider_auth_has_candidates",
"total": 2,
"available": 0,
"check_type": "management_metadata",
"canary_required": True,
},
},
)
gate = api._collect_cpa_credential_gate()
assert gate["safe_read_only"] is False
assert gate["zero_available"] is False
assert api._cpa_provider_auth_below_pool_target(gate, 2) is False
def test_runtime_validation_degrades_when_provider_auth_below_target(tmp_path, monkeypatch):
auth_one = tmp_path / "one.json"
auth_two = tmp_path / "two.json"
auth_one.write_text(json.dumps({"access_token": "token-one"}), encoding="utf-8")
auth_two.write_text(json.dumps({"access_token": "token-two"}), encoding="utf-8")
monkeypatch.setattr(api, "_auto_check_config", {"interval": 0, "target_seats": 3, "threshold": 10, "min_low": 1})
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
monkeypatch.setattr(api, "_auto_check_team_member_count", lambda *args, **kwargs: 3)
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [
{"email": "one@example.com", "status": accounts.STATUS_ACTIVE, "auth_file": str(auth_one)},
{"email": "two@example.com", "status": accounts.STATUS_ACTIVE, "auth_file": str(auth_two)},
],
)
monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", lambda _token: ("ok", {"primary_pct": 10}))
monkeypatch.setattr(
api,
"_collect_cpa_credential_gate",
lambda: {
"enabled": True,
"safe_read_only": True,
"management_ok": True,
"available": 1,
"total": 4,
"reason": "provider_auth_has_candidates",
"zero_available": False,
},
)
validation = api._log_task_runtime_validation("auto-rotate")
assert validation["ok"] is False
assert validation["severity"] == "degraded"
assert validation["operation_success"] is True
assert validation["pool_health_ok"] is False
assert validation["followup_required"] is True
assert validation["provider_auth_available"] == 1
assert validation["reason"] == "provider_auth_available=1/2"
assert validation["cpa_credential_gate"]["available"] == 1
def test_sanitize_account_keeps_exportable_main_account_active_without_live_quota(tmp_path, monkeypatch):
main_email = "owner@example.com"
auth_file = tmp_path / "codex-main.json"
auth_file.write_text("{}", encoding="utf-8")
monkeypatch.setattr(api, "_is_main_account_email", lambda email: email == main_email)
monkeypatch.setattr("autoteam.codex_auth.get_saved_main_auth_file", lambda: str(auth_file))
sanitized = api._sanitize_account(
{"email": main_email, "status": "exhausted", "auth_file": "/app/auths/missing.json"}
)
assert sanitized["is_main_account"] is True
assert sanitized["status"] == "active"
def test_sanitize_account_masks_disabled_non_main_status(monkeypatch):
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
sanitized = api._sanitize_account({"email": "user@example.com", "status": "active", "disabled": True})
assert sanitized["raw_status"] == "active"
assert sanitized["status"] == "disabled"
assert sanitized["disabled"] is True
def test_post_rotate_runs_final_sync_in_background(monkeypatch):
started = []
rotate_calls = []
def fake_start_task(command, func, params, *args, **kwargs):
started.append((command, func, params, args, kwargs))
return {"task_id": "rotate-task", "command": command, "params": params}
monkeypatch.setattr(api, "_start_task", fake_start_task)
monkeypatch.setattr(
"autoteam.manager.cmd_rotate",
lambda *args, **kwargs: rotate_calls.append((args, kwargs)),
)
result = api.post_rotate(api.TaskParams(target=3))
assert result["task_id"] == "rotate-task"
assert len(started) == 1
command, func, params, args, kwargs = started[0]
assert command == "rotate"
assert params == {"target": 3}
assert args == (3,)
assert kwargs == {}
func(3)
assert rotate_calls == [((3,), {"force_auth_repair": True, "background_post_sync": True})]
def test_post_fill_defers_sync_and_status_to_background(monkeypatch):
started = []
fill_calls = []
sync_calls = []
def fake_start_task(command, func, params, *args, **kwargs):
started.append((command, func, params, args, kwargs))
return {"task_id": "fill-task", "command": command, "params": params}
monkeypatch.setattr(api, "_start_task", fake_start_task)
monkeypatch.setattr(api._pw_executor, "run", lambda fn: (True, "ok", {}))
monkeypatch.setattr(
"autoteam.manager.cmd_fill",
lambda *args, **kwargs: fill_calls.append((args, kwargs)) or {"filled": True},
)
monkeypatch.setattr(
"autoteam.manager._schedule_post_task_sync",
lambda stage_label: sync_calls.append(stage_label),
)
result = api.post_fill(api.TaskParams(target=3))
assert result["task_id"] == "fill-task"
assert len(started) == 1
command, func, params, args, kwargs = started[0]
assert command == "fill"
assert params == {"target": 3, "leave_workspace": False}
assert args == (3,)
assert kwargs == {"leave_workspace": False}
assert func(3, leave_workspace=False) == {"filled": True}
assert fill_calls == [
((3,), {"leave_workspace": False, "post_sync": False, "print_status": False})
]
assert sync_calls == ["[填充]"]
def test_disable_and_enable_account_toggle_local_flag(tmp_path, monkeypatch):
accounts_file = tmp_path / "accounts.json"
monkeypatch.setattr(accounts, "ACCOUNTS_FILE", accounts_file)
monkeypatch.setattr(accounts, "get_admin_email", lambda: "owner@example.com")
monkeypatch.setattr(api, "_is_main_account_email", lambda email: email == "owner@example.com")
accounts.save_accounts(
[
{"email": "member@example.com", "status": "standby", "disabled": False},
{"email": "owner@example.com", "status": "active", "disabled": False},
]
)
disabled_result = api.post_disable_account("member@example.com")
enabled_result = api.post_enable_account("member@example.com")
assert disabled_result["disabled"] is True
assert disabled_result["account"]["status"] == "disabled"
assert disabled_result["account"]["raw_status"] == "standby"
assert enabled_result["disabled"] is False
assert enabled_result["account"]["status"] == "standby"
assert accounts.find_account(accounts.load_accounts(), "member@example.com")["disabled"] is False
def test_bulk_disable_accounts_updates_multiple_rows_and_skips_non_targets(tmp_path, monkeypatch):
accounts_file = tmp_path / "accounts.json"
monkeypatch.setattr(accounts, "ACCOUNTS_FILE", accounts_file)
monkeypatch.setattr(accounts, "get_admin_email", lambda: "owner@example.com")
monkeypatch.setattr(api, "_is_main_account_email", lambda email: email == "owner@example.com")
accounts.save_accounts(
[
{"email": "first@example.com", "status": "standby", "disabled": False},
{"email": "second@example.com", "status": "active", "disabled": False},
{"email": "already@example.com", "status": "standby", "disabled": True},
{"email": "owner@example.com", "status": "active", "disabled": False},
]
)
result = api.post_bulk_disable_accounts(
api.AccountDisableParams(
emails=[
"first@example.com",
"second@example.com",
"already@example.com",
"owner@example.com",
"missing@example.com",
"first@example.com",
]
)
)
stored = {acc["email"]: acc for acc in accounts.load_accounts()}
assert result["updated_count"] == 2
assert result["updated_emails"] == ["first@example.com", "second@example.com"]
assert result["unchanged_emails"] == ["already@example.com"]
assert result["skipped_main_accounts"] == ["owner@example.com"]
assert result["missing_emails"] == ["missing@example.com"]
assert stored["first@example.com"]["disabled"] is True
assert stored["second@example.com"]["disabled"] is True
assert stored["already@example.com"]["disabled"] is True
assert stored["owner@example.com"]["disabled"] is False
def test_bulk_enable_accounts_updates_disabled_rows_only(tmp_path, monkeypatch):
accounts_file = tmp_path / "accounts.json"
monkeypatch.setattr(accounts, "ACCOUNTS_FILE", accounts_file)
monkeypatch.setattr(accounts, "get_admin_email", lambda: "owner@example.com")
monkeypatch.setattr(api, "_is_main_account_email", lambda email: email == "owner@example.com")
accounts.save_accounts(
[
{"email": "first@example.com", "status": "standby", "disabled": True},
{"email": "second@example.com", "status": "active", "disabled": True},
{"email": "already@example.com", "status": "standby", "disabled": False},
{"email": "owner@example.com", "status": "active", "disabled": False},
]
)
result = api.post_bulk_enable_accounts(
api.AccountDisableParams(
emails=[
"first@example.com",
"second@example.com",
"already@example.com",
"owner@example.com",
"missing@example.com",
]
)
)
stored = {acc["email"]: acc for acc in accounts.load_accounts()}
assert result["updated_count"] == 2
assert result["updated_emails"] == ["first@example.com", "second@example.com"]
assert result["unchanged_emails"] == ["already@example.com"]
assert result["skipped_main_accounts"] == ["owner@example.com"]
assert result["missing_emails"] == ["missing@example.com"]
assert stored["first@example.com"]["disabled"] is False
assert stored["second@example.com"]["disabled"] is False
assert stored["already@example.com"]["disabled"] is False
assert stored["owner@example.com"]["disabled"] is False
def test_run_task_records_progress_history(monkeypatch):
task_id = "progresshist"
api._tasks[task_id] = {
"task_id": task_id,
"command": "auto-rotate",
"params": {},
"status": "pending",
"created_at": time.time(),
"started_at": None,
"finished_at": None,
"result": None,
"error": None,
"last_progress_at": time.time(),
"progress": "pending",
"progress_history": [],
}
monkeypatch.setattr(api, "_log_task_runtime_validation", lambda _command: {"ok": True, "severity": "ok"})
def fake_work():
api.bump_task_progress("rotate:sync_team")
api.bump_task_progress("rotate:check_quota")
return {"ok": True}
try:
api._run_task(task_id, fake_work)
task = api._tasks[task_id]
stages = [item["stage"] for item in task["progress_history"]]
assert stages == ["started", "rotate:sync_team", "rotate:check_quota", "runtime_validation", "finished"]
assert task["progress"] == "rotate:check_quota"
assert task["progress_history"][0]["duration_sec"] >= 0
assert task["progress_history"][-1]["stage"] == "finished"
finally:
api._tasks.pop(task_id, None)
def test_get_status_counts_disabled_and_skips_disabled_quota_checks(tmp_path, monkeypatch):
enabled_auth = tmp_path / "enabled.json"
disabled_auth = tmp_path / "disabled.json"
enabled_auth.write_text(json.dumps({"access_token": "token-enabled"}), encoding="utf-8")
disabled_auth.write_text(json.dumps({"access_token": "token-disabled"}), encoding="utf-8")
monkeypatch.setattr(
"autoteam.accounts.load_accounts",
lambda: [
{"email": "enabled@example.com", "status": "active", "auth_file": str(enabled_auth), "disabled": False},
{"email": "disabled@example.com", "status": "active", "auth_file": str(disabled_auth), "disabled": True},
],
)
monkeypatch.setattr(api, "_is_main_account_email", lambda _email: False)
seen_tokens = []
def fake_check_quota(access_token):
seen_tokens.append(access_token)
return (
"ok",
{
"primary_pct": 15,
"primary_resets_at": 1710000000,
"weekly_pct": 5,
"weekly_resets_at": 1710600000,
},
)
monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", fake_check_quota)
result = api.get_status()
assert seen_tokens == ["token-enabled"]
assert {item["email"]: item["status"] for item in result["accounts"]} == {
"enabled@example.com": "active",
"disabled@example.com": "disabled",
}
assert result["summary"] == {
"active": 1,
"standby": 0,
"exhausted": 0,
"pending": 0,
"personal": 0,
"auth_invalid": 0,
"orphan": 0,
"disabled": 1,
"total": 2,
}
def test_post_setup_save_keeps_cpa_url_required_and_generates_api_key(monkeypatch):
written = {}
def fake_write_env(key, value):
written[key] = value
monkeypatch.setattr("autoteam.setup_wizard._write_env", fake_write_env)
monkeypatch.setattr("autoteam.setup_wizard._verify_cloudmail", lambda: True)
monkeypatch.setattr("autoteam.setup_wizard._verify_cpa", lambda: True)
monkeypatch.setattr("secrets.token_urlsafe", lambda _n: "generated-token")
monkeypatch.setattr("importlib.reload", lambda module: module)
monkeypatch.setattr(api, "API_KEY", "")
monkeypatch.delenv("CPA_URL", raising=False)
monkeypatch.delenv("API_KEY", raising=False)
result = api.post_setup_save(
api.SetupConfig(
CLOUDMAIL_BASE_URL="http://mail.example.com",
CLOUDMAIL_EMAIL="admin@example.com",
CLOUDMAIL_PASSWORD="secret",
CLOUDMAIL_DOMAIN="@example.com",
CPA_URL="",
CPA_KEY="key-1",
PLAYWRIGHT_PROXY_URL="",
PLAYWRIGHT_PROXY_BYPASS="",
API_KEY="",
)
)
assert written["CPA_URL"] == "http://127.0.0.1:8317"
assert written["API_KEY"] == "generated-token"
assert result["api_key"] == "generated-token"
assert api.API_KEY == "generated-token"
def test_post_setup_save_normalizes_rotation_account_strategy(monkeypatch):
written = {}
def fake_write_env(key, value):
written[key] = value
monkeypatch.setattr("autoteam.setup_wizard._write_env", fake_write_env)
monkeypatch.setattr("autoteam.setup_wizard._verify_cloudmail", lambda: True)
monkeypatch.setattr("autoteam.setup_wizard._verify_cpa", lambda: True)
monkeypatch.setattr("importlib.reload", lambda module: module)
monkeypatch.setattr(api, "API_KEY", "old-key")
monkeypatch.setenv("API_KEY", "old-key")
result = api.post_setup_save(
api.SetupConfig(
CLOUDMAIL_BASE_URL="http://mail.example.com",
CLOUDMAIL_PASSWORD="secret",
CLOUDMAIL_DOMAIN="@example.com",
CPA_URL="http://127.0.0.1:8317",
CPA_KEY="key-1",
API_KEY="old-key",
ROTATE_NEW_ACCOUNT_MODE="DOMAIN_AUTO_JOIN_FIRST",
AUTOTEAM_AUTO_JOIN_DOMAINS="@Example.com, other.example.",
ROTATE_DOMAIN_AUTO_JOIN_FALLBACK_INVITE=True,
)
)
assert result["message"] == "配置保存成功"
assert written["ROTATE_NEW_ACCOUNT_MODE"] == "domain_auto_join_first"
assert written["AUTOTEAM_AUTO_JOIN_DOMAINS"] == "example.com,other.example"
assert written["ROTATE_DOMAIN_AUTO_JOIN_FALLBACK_INVITE"] == "true"
def test_post_setup_save_rejects_invalid_rotation_account_strategy(monkeypatch):
monkeypatch.setattr("secrets.token_urlsafe", lambda _n: "generated-token")
result = api.post_setup_save(
api.SetupConfig(
ROTATE_NEW_ACCOUNT_MODE="invite-maybe",
ROTATE_DOMAIN_AUTO_JOIN_FALLBACK_INVITE="maybe",
)
)
assert result.status_code == 400
body = json.loads(result.body.decode("utf-8"))
assert body["message"] == "ROTATE_NEW_ACCOUNT_MODE 必须是 domain_auto_join_first、invite_first 或 direct_first"