from autoteam import manager class _FakeChatGPT: def __init__(self): self.browser = True self.started = 0 self.stopped = 0 def start(self): self.browser = True self.started += 1 def stop(self): self.browser = False self.stopped += 1 class _FakeMailClient: def login(self): return None def test_cmd_rotate_skips_google_accounts_during_auto_reuse(monkeypatch): import autoteam.config as config chatgpt = _FakeChatGPT() count_values = iter([2, 3]) events = [] monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", False) monkeypatch.setattr(manager, "sync_account_states", lambda: events.append(("sync_account_states", None))) monkeypatch.setattr(manager, "cmd_check", lambda: events.append(("cmd_check", None))) monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt) monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient()) monkeypatch.setattr(manager, "load_accounts", lambda: []) monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(count_values)) monkeypatch.setattr( manager, "get_standby_accounts", lambda: [ {"email": "bubblehuntr@gmail.com"}, {"email": "old-2@example.com"}, ], ) monkeypatch.setattr( manager, "reinvite_account", lambda _chatgpt, _mail, acc: events.append(("reinvite", acc["email"])) or True, ) monkeypatch.setattr( manager, "create_new_account", lambda _chatgpt, _mail: events.append(("create", None)) or True, ) monkeypatch.setattr(manager, "sync_to_cpa", lambda: events.append(("sync_to_cpa", None))) manager.cmd_rotate(target_seats=3) assert events == [ ("sync_account_states", None), ("cmd_check", None), ("reinvite", "old-2@example.com"), ("sync_to_cpa", None), ] assert chatgpt.stopped == 1 def test_cmd_rotate_can_defer_final_sync_when_running_as_api_task(monkeypatch): chatgpt = _FakeChatGPT() events = [] monkeypatch.setattr(manager, "sync_account_states", lambda: events.append(("sync_account_states", None))) monkeypatch.setattr(manager, "cmd_check", lambda: events.append(("cmd_check", None))) monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt) monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient()) monkeypatch.setattr(manager, "load_accounts", lambda: []) monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: 3) monkeypatch.setattr(manager, "_count_pool_active_accounts", lambda *args, **kwargs: 2) monkeypatch.setattr(manager, "get_standby_accounts", lambda: []) monkeypatch.setattr( manager, "create_new_account", lambda _chatgpt, _mail: (_ for _ in ()).throw(AssertionError("should not create when member count is full")), ) monkeypatch.setattr( manager, "sync_to_cpa", lambda: (_ for _ in ()).throw(AssertionError("final sync should be scheduled, not run inline")), ) monkeypatch.setattr( manager, "_schedule_post_task_sync", lambda stage_label: events.append(("schedule_post_sync", stage_label)), ) manager.cmd_rotate(target_seats=3, background_post_sync=True) assert events == [ ("sync_account_states", None), ("cmd_check", None), ("schedule_post_sync", "[轮转]"), ] def test_replaceable_pool_blocker_reason_reports_concrete_evidence(tmp_path): auth_file = tmp_path / "codex-auth.json" auth_file.write_text("{}", encoding="utf-8") assert ( manager._replaceable_pool_blocker_reason( {"email": "missing@example.com", "status": manager.STATUS_ACTIVE, "auth_file": ""} ) == "missing_auth" ) assert ( manager._replaceable_pool_blocker_reason( {"email": "invalid@example.com", "status": manager.STATUS_AUTH_INVALID} ) == "auth_invalid" ) assert ( manager._replaceable_pool_blocker_reason( {"email": "exhausted@example.com", "status": manager.STATUS_EXHAUSTED} ) == "quota_exhausted" ) assert ( manager._replaceable_pool_blocker_reason( { "email": "managed-protected@example.com", "status": manager.STATUS_ACTIVE, "auth_file": str(auth_file), "mail_account_id": 1, "auth_retry_paused": True, "protect_team_seat": True, } ) == "auth_retry_paused" ) assert ( manager._replaceable_pool_blocker_reason( { "email": "manual-protected@example.com", "status": manager.STATUS_ACTIVE, "auth_file": str(auth_file), "auth_retry_paused": True, "protect_team_seat": True, } ) is None ) def test_create_new_account_uses_domain_auto_join_before_invite(monkeypatch): chatgpt = _FakeChatGPT() events = [] monkeypatch.setenv("ROTATE_NEW_ACCOUNT_MODE", "domain_auto_join_first") monkeypatch.setenv("AUTOTEAM_AUTO_JOIN_DOMAINS", "example.com") monkeypatch.setattr(manager, "get_mail_domain", lambda: "@example.com") monkeypatch.setattr(manager, "_prepare_remote_capacity_for_new_seat", lambda *_args, **_kwargs: True) monkeypatch.setattr( manager, "create_account_via_invite", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("invite should not run for auto-join domain")), ) monkeypatch.setattr(manager, "create_account_direct", lambda *_args, **_kwargs: events.append("direct") or "new@example.com") result = manager.create_new_account(chatgpt, _FakeMailClient()) assert result == "new@example.com" assert events == ["direct"] assert chatgpt.stopped == 1 def test_create_new_account_invite_first_mode_preserves_invite_order(monkeypatch): chatgpt = _FakeChatGPT() events = [] monkeypatch.setenv("ROTATE_NEW_ACCOUNT_MODE", "invite_first") monkeypatch.setattr(manager, "create_account_via_invite", lambda *_args, **_kwargs: events.append("invite") or "new@example.com") monkeypatch.setattr( manager, "create_account_direct", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("direct should not run when invite succeeds")), ) result = manager.create_new_account(chatgpt, _FakeMailClient()) assert result == "new@example.com" assert events == ["invite"] def test_create_new_account_domain_auto_join_falls_back_to_invite(monkeypatch): chatgpt = _FakeChatGPT() events = [] monkeypatch.setenv("ROTATE_NEW_ACCOUNT_MODE", "domain_auto_join_first") monkeypatch.setenv("AUTOTEAM_AUTO_JOIN_DOMAINS", "example.com") monkeypatch.setenv("ROTATE_DOMAIN_AUTO_JOIN_FALLBACK_INVITE", "true") monkeypatch.setattr(manager, "get_mail_domain", lambda: "@example.com") monkeypatch.setattr(manager, "_prepare_remote_capacity_for_new_seat", lambda *_args, **_kwargs: True) monkeypatch.setattr(manager, "create_account_direct", lambda *_args, **_kwargs: events.append("direct") or None) monkeypatch.setattr(manager, "create_account_via_invite", lambda *_args, **_kwargs: events.append("invite") or "new@example.com") result = manager.create_new_account(chatgpt, _FakeMailClient()) assert result == "new@example.com" assert events == ["direct", "invite"] def test_create_new_account_does_not_retry_direct_after_invite_fallback_failure(monkeypatch): chatgpt = _FakeChatGPT() events = [] monkeypatch.setenv("ROTATE_NEW_ACCOUNT_MODE", "domain_auto_join_first") monkeypatch.setenv("AUTOTEAM_AUTO_JOIN_DOMAINS", "example.com") monkeypatch.setattr(manager, "get_mail_domain", lambda: "@example.com") monkeypatch.setattr(manager, "_prepare_remote_capacity_for_new_seat", lambda *_args, **_kwargs: True) monkeypatch.setattr(manager, "create_account_direct", lambda *_args, **_kwargs: events.append("direct") or None) monkeypatch.setattr(manager, "create_account_via_invite", lambda *_args, **_kwargs: events.append("invite") or None) assert manager.create_new_account(chatgpt, _FakeMailClient()) is None assert events == ["direct", "invite"] def test_create_new_account_domain_auto_join_respects_allowlist(monkeypatch): chatgpt = _FakeChatGPT() events = [] monkeypatch.setenv("ROTATE_NEW_ACCOUNT_MODE", "domain_auto_join_first") monkeypatch.setenv("AUTOTEAM_AUTO_JOIN_DOMAINS", "other.example") monkeypatch.setattr(manager, "get_mail_domain", lambda: "@example.com") monkeypatch.setattr( manager, "create_account_direct", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("direct should not run for unlisted domain")), ) monkeypatch.setattr(manager, "create_account_via_invite", lambda *_args, **_kwargs: events.append("invite") or "new@example.com") result = manager.create_new_account(chatgpt, _FakeMailClient()) assert result == "new@example.com" assert events == ["invite"] def test_cmd_rotate_removes_replaceable_blocker_before_creating_replacement(monkeypatch): import autoteam.config as config chatgpt = _FakeChatGPT() accounts = [{"email": "blocked@example.com", "status": manager.STATUS_ACTIVE, "auth_file": ""}] counts = iter([3, 2, 2, 3]) events = [] monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", True) monkeypatch.setattr(manager, "sync_account_states", lambda: events.append(("sync_account_states", None))) monkeypatch.setattr(manager, "cmd_check", lambda: events.append(("cmd_check", None))) monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt) monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient()) monkeypatch.setattr(manager, "load_accounts", lambda: accounts) monkeypatch.setattr(manager, "get_team_member_count", lambda _chatgpt: next(counts)) monkeypatch.setattr(manager, "get_standby_accounts", lambda: []) monkeypatch.setattr(manager, "time", manager.time) monkeypatch.setattr(manager.time, "sleep", lambda *_args, **_kwargs: None) def fake_update(email, **kwargs): events.append(("update", email, kwargs.get("status"), kwargs.get("_reason"))) for acc in accounts: if acc["email"] == email: acc.update(kwargs) def fake_remove(_chatgpt, email, *, return_status=False, **_kwargs): events.append(("remove", email)) return "removed" if return_status else True def fake_create(_chatgpt, _mail): events.append(("create", None)) accounts.append( { "email": "new@example.com", "status": manager.STATUS_ACTIVE, "auth_file": "auth.json", } ) return "new@example.com" monkeypatch.setattr(manager, "update_account", fake_update) monkeypatch.setattr(manager, "remove_from_team", fake_remove) monkeypatch.setattr(manager, "create_new_account", fake_create) monkeypatch.setattr(manager, "_validate_managed_account_operational", lambda *_args, **_kwargs: True) monkeypatch.setattr(manager, "sync_to_cpa", lambda: events.append(("sync_to_cpa", None))) manager.cmd_rotate(target_seats=3) assert events.index(("remove", "blocked@example.com")) < events.index(("create", None)) assert ("update", "blocked@example.com", manager.STATUS_STANDBY, "missing_auth") in events def test_cmd_rotate_target2_refills_after_exhausted_removal_despite_transient_overcount(tmp_path, monkeypatch): import autoteam.config as config chatgpt = _FakeChatGPT() old_auth = tmp_path / "old.json" standby_auth = tmp_path / "standby.json" old_auth.write_text('{"access_token": "old-token"}', encoding="utf-8") standby_auth.write_text('{"access_token": "standby-token"}', encoding="utf-8") state = { "team_count": 2, "accounts": [ { "email": "old@example.com", "status": manager.STATUS_EXHAUSTED, "auth_file": str(old_auth), "last_quota": {"primary_pct": 100, "primary_resets_at": 1_700_001_000}, }, { "email": "standby@example.com", "status": manager.STATUS_STANDBY, "auth_file": str(standby_auth), "last_quota": {"primary_pct": 10, "primary_resets_at": 1_700_000_000}, }, ], } counts = iter([2, 3, 2]) events = [] def fake_load_accounts(): return [dict(acc) for acc in state["accounts"]] def fake_update(email, **kwargs): events.append(("update", email, kwargs.get("status"), kwargs.get("_reason"))) for acc in state["accounts"]: if acc["email"] == email: acc.update(kwargs) return def fake_count(_chatgpt): count = next(counts) events.append(("count", count)) return count def fake_wait(_chatgpt, **kwargs): events.append(("wait_capacity", kwargs["removed_email"], kwargs["target"])) return 3, False def fake_remove(_chatgpt, email, *, return_status=False, **_kwargs): events.append(("remove", email, return_status)) state["team_count"] -= 1 return "removed" if return_status else True def fake_quota(token, *args, **kwargs): events.append(("quota", token)) if token == "old-token": return ( "exhausted", {"quota_info": {"primary_pct": 100, "weekly_pct": 100}, "resets_at": 1_700_001_000}, ) return "ok", {"primary_pct": 10, "weekly_pct": 10} def fake_reinvite(_chatgpt, _mail, acc): events.append(("reinvite", acc["email"])) state["team_count"] += 1 fake_update(acc["email"], status=manager.STATUS_ACTIVE, last_active_at=1_700_000_000) return True monkeypatch.setattr(config, "ROTATE_SKIP_REUSE", False) monkeypatch.setattr(manager, "sync_account_states", lambda: events.append(("sync_account_states", None))) monkeypatch.setattr(manager, "cmd_check", lambda: events.append(("cmd_check", None))) monkeypatch.setattr(manager, "ChatGPTTeamAPI", lambda: chatgpt) monkeypatch.setattr(manager, "CloudMailClient", lambda: _FakeMailClient()) monkeypatch.setattr(manager, "load_accounts", fake_load_accounts) monkeypatch.setattr(manager, "update_account", fake_update) monkeypatch.setattr(manager, "get_team_member_count", fake_count) monkeypatch.setattr(manager, "_wait_for_remote_capacity_after_removal", fake_wait) monkeypatch.setattr( manager, "get_standby_accounts", lambda: [dict(acc) for acc in state["accounts"] if acc["status"] == manager.STATUS_STANDBY], ) monkeypatch.setattr(manager, "check_codex_quota", fake_quota) monkeypatch.setattr(manager, "reinvite_account", fake_reinvite) monkeypatch.setattr( manager, "create_new_account", lambda *_args, **_kwargs: (_ for _ in ()).throw( AssertionError("should reuse standby after removing exhausted blocker, not create before remove") ), ) monkeypatch.setattr(manager, "remove_from_team", fake_remove) monkeypatch.setattr(manager, "sync_to_cpa", lambda: events.append(("sync_to_cpa", None))) manager.cmd_rotate(target_seats=2) assert events.index(("remove", "old@example.com", True)) < events.index(("reinvite", "standby@example.com")) assert ("count", 3) in events assert events.count(("remove", "old@example.com", True)) == 1 assert state["team_count"] == 2 assert next(acc for acc in state["accounts"] if acc["email"] == "old@example.com")["status"] == manager.STATUS_STANDBY assert next(acc for acc in state["accounts"] if acc["email"] == "standby@example.com")[ "status" ] == manager.STATUS_ACTIVE