Files
live-hub-py/tests/test_automation_ck_relogin.py
T
yml2213 00125fbfc4 refactor(account): S2 读写一次切换 —— CK/健康/重登彻底搬到账号,登录记录退化为历史
S1 加好了列,本提交把**读与写一起切**过去。这是设计里「同一份数据的读与写必须
在同一个提交里切」的要求:先切读或先切写都会出现「写账号、读记录」的中间态,
跑起来不报错,只是默默用错数据。

## 核心改动

**判定「谁有 CK」不再借道登录记录**
- `cookie_holding_filter()` / `cookie_operable_filter()` / `cookie_holding_account_ids_query()`
  / `visible_douyu_task_accounts()` 全部改为作用于 `Account`;
- `cookie_operable_filter()` 的「在途排除」仍查 `login_tasks`(那是**某次尝试**的属性),
  用 `NOT EXISTS` 子查询 + 迁移 0042 新增的 `(account_id, attempt_status)` 索引;
- 删除死代码 `latest_cookie_login_task()`;`latest_cookie()` 改为直接读账号。

**CK 写入**
- `_write_login_success()` 写 `Account.cookie / cookie_updated_at / cookie_source`;
- `_mark_attempt_failed()` 改看**账号**有没有 CK(决定是否沿用旧的失败语义);
- 检测模式的 `task.cookie = ""` **不再清空账号的 CK**(见下「行为变更」)。

**自动化作业按账号调度**
- `ck_health`:`_select_tasks` → `_select_accounts`,`db.query(Account)`;新增
  `count_remaining()`(刻意不带 LIMIT,否则积压数恒等于批次上限);
- `ck_relogin`:同样按账号选行;结算改为「账号 + 本轮尝试记录」,
  退避进度写账号;`settle_fresh_results(batch_id)` 按 `attempt_batch_id` 配对,
  `run_ck_relogin` 把 **account_ids** 交给 runner;
- 顺带让**没有任何登录历史的账号**也能被巡检/重登(以前它们永远进不了队列)。

**登录批次**
- `LoginBatchRunner` 去掉 `relogin_task_ids`,重登与登录统一走 `account_ids`;
- 批次行复用逻辑重写:只保证「同一账号不并发两行」,不再关心「哪一行持有 CK」。
  失败行复用同时看 `attempt_status`(账号有 CK 时 `_mark_attempt_failed` 刻意不动
  `status`,只看 status 会永远命不中 → 每次失败刷一行)。

**删除护栏语义变更(用户可感知)**
- 去掉「持有 CK 的记录不可删」——CK 在账号上,删历史不再影响登录态,历史终于可清理;
- **保留**「在途尝试不可删」(批次线程正拿着那条记录)。

## 行为变更(需评审)

1. **账号检测不再清空 CK**。旧代码在 check 模式写 `task.cookie = ""`,而那时记录的
   cookie 就是账号唯一凭证 —— 一次「批量检测」会把账号登录态删掉。解耦后
   「检测账号凭据」与「CK 是否有效」本就是两件事(后者归 `ck_health` 判),
   继续清空等于凭空制造不可逆的数据丢失(重登当前成功率极低)。
2. **Cookie 页的记录单位从「登录记录」变成「账号」**,所有接口的 id 入参/出参
   变成 `account_id`;`DELETE /cookies/{id}` 清的是该账号的 CK,**必写审计**。
3. 重复检测从「同一账号多行持 CK」改为「**同一个 CK 出现在多个账号上**」——
   后者才是真风险(两个账号共用一个登录态)。用 sha256 指纹分组:
   CK 是 AES-GCM + 随机 nonce 加密,**同一明文每次密文都不同**,比密文永远比不出重复。

## 修掉一个真实并发缺陷

`_write_login_success` 原先被传入 `self.db`(runner 主会话),而它是在**工作线程**里
用 `worker_db` 执行的。`emit()` 会往 `account_events` 插一行,其 `account_id` 外键对
`accounts` 那一行加**共享锁**;这把锁加在另一个会话上,紧接着本会话的
`UPDATE accounts SET cookie=...` 就被自己人挡住 —— 实测 **50 秒后
`Lock wait timeout exceeded`,登录明明成功却写不回 CK**(改造前 UPDATE 打在
`login_tasks` 上所以没撞上)。改为传 `worker_db`:事件本就该和状态变更同事务提交,
顺带修掉跨线程共用一个 Session 的隐患。

## 验证

- 全量 pytest **368 passed / 6 skipped**;迁移链 3 passed;
- 前端 `tsc -b && vite build` 通过;
- `ruff check` / `ruff format --check` 全绿(顺带修掉 main 上遗留的 F821);
- 新增/改写的关键回归:**删掉全部登录历史后账号的 CK 与健康状态仍在**、
  **无登录历史的账号也能进巡检队列**、结算不碰 CK、删除护栏改为在途语义。

仍未做(设计里排期的后续阶段):`account_lifecycle` 状态收口(S3)、事件发射点补齐(S4)、
业务事实直发事件(S5)、账号页重构(S6)。注意 `status` 目前只有模型默认值与迁移回填,
所以 `disabled/archived` 还没有任何写入方——S3 落地时要把自动化选行的状态过滤一起补上。
2026-10-04 14:10:13 +08:00

568 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P1b 回归:CK 自动重登的候选筛选、失败分型与结算。
两件事必须一起守住:
1. **分型正确**:线上 415 个重登失败里只有 35%(极验)真的需要人工,另外 64%
(取码失败 + 重启中断)其实可自动重试。分错方向就是白扔可救名额,或者让
极验账号反复撞风控。
2. **选行安全**:只挑「巡检判定失效 + 允许自动管理 + 没有在途尝试 + 退避到期 +
没转人工」的行;转人工的类型绝不能被再次自动重登。
结算逻辑(``settle_results``)与选行查询(``build_select_query``)都刻意抽成了
纯函数/可编译对象,这里不碰真实登录链路。
CK 解耦(2026-10-04)后,**选行与退避进度的单位是账号**:``ck_check_status`` 与
``relogin_*`` 都落到了 ``accounts`` 上,``login_tasks`` 退化为纯登录尝试历史
(本轮只读它的 ``attempt_status`` / ``attempt_message`` / ``attempt_batch_id``)。
所以这里建的是 Account;只有「在途」这类**某次尝试**的属性才补一条 LoginTask。
"""
from datetime import UTC, datetime, timedelta
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AutomationJob, LoginTask, User
from web.backend.services.automation import register_default_jobs, reset_registry
from web.backend.services.automation.jobs import ck_relogin
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
# 作业注册表是模块级全局,测试之间会互相污染;重置成生产启动时的状态,
# 否则 list_jobs() 里的 ensure_jobs() 建不出作业行。
reset_registry()
register_default_jobs()
self.admin = User(
username="admin", password_hash="h", role="super_admin", is_active=True
)
self.session.add(self.admin)
self.session.commit()
def teardown_method(self):
self.session.close()
def make_account(
self,
name="acc",
*,
ck_check_status="invalid",
cookie="acf_uid=1",
relogin_attempts=0,
relogin_next_at=None,
relogin_failure_kind="",
relogin_last_error="",
auto_managed=True,
):
"""建一个账号 —— CK 与重登进度都是账号自己的属性。
``login_tasks`` 的 ``cookie`` / ``ck_check_*`` / ``relogin_*`` 旧列已不再被
新代码读写,这里也不往里写值:留作回退的列不该继续被测试固化。
"""
account = Account(
username=name,
password="p",
email=f"{name}@e.com",
email_password="ep",
auto_managed=auto_managed,
cookie=cookie,
ck_check_status=ck_check_status,
relogin_attempts=relogin_attempts,
relogin_next_at=relogin_next_at,
relogin_failure_kind=relogin_failure_kind,
relogin_last_error=relogin_last_error,
)
self.session.add(account)
self.session.commit()
return account
def make_attempt(
self,
account,
*,
attempt_status="",
attempt_message="",
attempt_batch_id="",
status="success",
):
"""建一条登录尝试历史(本轮重登的那条记录)。"""
task = LoginTask(
batch_id="b",
account_id=account.id,
created_by=self.admin.id,
status=status,
attempt_status=attempt_status,
attempt_message=attempt_message,
attempt_batch_id=attempt_batch_id,
)
self.session.add(task)
self.session.commit()
return task
def job(self, batch_size=10):
return AutomationJob(
key=ck_relogin.JOB_KEY,
interval_seconds=6 * 3600,
batch_size=batch_size,
concurrency=3,
)
class TestFailureClassification(_Base):
def test_quota_wins_over_code(self):
"""「发送次数已达上限」既含验证码又含上限,必须优先判配额。
判成 email_code 会按小时反复撞配额;判成 email_quota 才退避到次日。
"""
msg = "重新登录失败: 提交验证码失败: 您的邮箱验证码发送次数已达上限,请改天再试!(旧 Cookie 已保留)"
assert ck_relogin.classify_failure(msg) == "email_quota"
def test_captcha_is_manual(self):
assert (
ck_relogin.classify_failure(
"重新登录失败: 极验验证失败: slide(旧 Cookie 已保留)"
)
== "captcha"
)
assert "captcha" in ck_relogin.MANUAL_KINDS
def test_email_code_is_retryable(self):
assert (
ck_relogin.classify_failure(
"重新登录失败: 提交验证码失败: 邮箱验证码错误,请重新输入"
)
== "email_code"
)
def test_interrupted_is_retryable(self):
assert (
ck_relogin.classify_failure("登录已中断(服务重启),旧 Cookie 已保留")
== "interrupted"
)
assert (
ck_relogin.classify_failure("重新登录已停止,旧 Cookie 已保留")
== "interrupted"
)
def test_credential_is_manual(self):
assert (
ck_relogin.classify_failure("重新登录失败: 账号缺少密码或邮箱")
== "credential"
)
assert "credential" in ck_relogin.MANUAL_KINDS
def test_unknown_defaults_to_retryable(self):
"""未知文案按可重试处理:有每日上限兜底,别把可能只是抖动的账号推给人工。"""
assert ck_relogin.classify_failure("某种没见过的报错") == "network"
assert ck_relogin.classify_failure("") == "network"
class TestBackoff(_Base):
def test_ladder_is_escalating(self):
now = datetime(2026, 10, 2, 12, 0, tzinfo=UTC)
first = ck_relogin._next_retry_at("email_code", 1, now) - now
second = ck_relogin._next_retry_at("email_code", 2, now) - now
third = ck_relogin._next_retry_at("email_code", 3, now) - now
assert first == timedelta(minutes=60)
assert second == timedelta(minutes=180)
assert third == timedelta(minutes=720)
def test_quota_goes_to_next_day_with_jitter(self):
now = datetime(2026, 10, 2, 12, 0, tzinfo=UTC)
nxt = ck_relogin._next_retry_at("email_quota", 1, now)
assert nxt.date() == (now + timedelta(days=1)).date()
assert nxt.hour == 0
assert 0 <= nxt.minute <= ck_relogin.QUOTA_JITTER_MINUTES
class TestSelection(_Base):
"""选行:``build_select_query`` 现在按**账号**返回,断言取 ``account.id``。"""
def _ids(self, job):
now = datetime.now(UTC)
return [
account.id
for account in ck_relogin.build_select_query(
self.session, job, now, max_attempts=2
).all()
]
def test_picks_invalid_with_expired_backoff(self):
job = self.job()
ready = self.make_account("ready")
assert self._ids(job) == [ready.id]
def test_skips_valid_and_empty_cookie_and_opted_out(self):
job = self.job()
keep = self.make_account("keep")
self.make_account("valid", ck_check_status="valid")
self.make_account("nocookie", cookie="")
self.make_account("optedout", auto_managed=False)
assert self._ids(job) == [keep.id]
def test_skips_inflight_attempt(self):
"""「在途」是某次尝试的属性,仍然要靠 login_tasks 表达。"""
job = self.job()
keep = self.make_account("keep")
running = self.make_account("running")
self.make_attempt(running, attempt_status="running")
pending = self.make_account("pending")
self.make_attempt(pending, attempt_status="pending")
assert self._ids(job) == [keep.id]
def test_respects_backoff_window(self):
job = self.job()
due = self.make_account(
"due", relogin_next_at=datetime.now(UTC) - timedelta(minutes=1)
)
self.make_account(
"later", relogin_next_at=datetime.now(UTC) + timedelta(hours=3)
)
assert self._ids(job) == [due.id]
def test_skips_when_attempts_exhausted(self):
job = self.job()
keep = self.make_account("keep")
self.make_account("exhausted", relogin_attempts=2)
assert self._ids(job) == [keep.id]
def test_never_retries_manual_kinds(self):
"""已判定极验/凭证问题的账号,绝不能再次自动重登。"""
job = self.job()
keep = self.make_account("keep")
self.make_account("captcha", relogin_failure_kind="captcha")
self.make_account("credential", relogin_failure_kind="credential")
assert self._ids(job) == [keep.id]
def test_retryable_kinds_stay_eligible(self):
job = self.job()
code = self.make_account("code", relogin_failure_kind="email_code")
net = self.make_account("net", relogin_failure_kind="network")
assert sorted(self._ids(job)) == sorted([code.id, net.id])
def test_generated_sql_is_mysql_compatible(self):
from sqlalchemy.dialects import mysql
query = ck_relogin.build_select_query(
self.session, self.job(), datetime.now(UTC), max_attempts=2
)
sql = str(query.statement.compile(dialect=mysql.dialect())).upper()
assert "NULLS FIRST" not in sql and "NULLS LAST" not in sql
def test_batch_size_caps_the_round(self):
job = self.job(batch_size=3)
for index in range(6):
self.make_account(f"a{index}")
assert len(self._ids(job)) == 3
class TestSettlement(_Base):
"""结算:成功清零;失败按分型决定重试还是转人工。
解耦后决策看「账号 + 本轮的尝试记录」:成败读 ``LoginTask.attempt_status``,
退避进度写**账号**的 ``relogin_*``。
"""
def _settle(self, pairs, *, max_attempts=2):
now = datetime.now(UTC)
fresh = {account.id: (account, task) for account, task in pairs}
counters = ck_relogin.settle_results(
self.session,
fresh,
[account.id for account, _ in pairs],
now=now,
max_attempts=max_attempts,
)
self.session.commit()
return counters, now
def test_success_resets_state(self):
account = self.make_account(
"ok",
relogin_attempts=1,
relogin_failure_kind="email_code",
relogin_last_error="旧的报错",
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
)
task = self.make_attempt(account, attempt_status="success")
counters, _ = self._settle([(account, task)])
self.session.refresh(account)
assert counters["valid"] == 1 and counters["invalid"] == 0
assert account.relogin_attempts == 0
assert account.relogin_next_at is None
assert account.relogin_failure_kind == ""
assert account.relogin_last_error == ""
def test_retryable_failure_schedules_next_attempt(self):
account = self.make_account("retry")
task = self.make_attempt(
account,
attempt_status="failed",
attempt_message="等待验证码超时(旧 Cookie 已保留)",
)
counters, now = self._settle([(account, task)])
self.session.refresh(account)
assert counters["invalid"] == 1 and counters["retry"] == 1
assert account.relogin_failure_kind == "email_code"
assert account.relogin_attempts == 1
assert account.relogin_next_at is not None and account.relogin_next_at > now
assert "旧 Cookie" in account.relogin_last_error
def test_captcha_goes_to_manual_without_schedule(self):
account = self.make_account("cap")
task = self.make_attempt(
account, attempt_status="failed", attempt_message="极验验证失败: slide"
)
counters, _ = self._settle([(account, task)])
self.session.refresh(account)
assert counters["manual"] == 1 and counters["retry"] == 0
assert account.relogin_failure_kind == "captcha"
assert account.relogin_next_at is None # 不再自动排期
def test_quota_backs_off_to_next_day(self):
account = self.make_account("quota")
task = self.make_attempt(
account,
attempt_status="failed",
attempt_message="您的邮箱验证码发送次数已达上限,请改天再试!",
)
counters, now = self._settle([(account, task)])
self.session.refresh(account)
assert counters["email_quota"] == 1 and counters["retry"] == 1
assert account.relogin_next_at.date() == (now + timedelta(days=1)).date()
def test_attempts_exhausted_stops_retrying(self):
account = self.make_account("used", relogin_attempts=1)
task = self.make_attempt(
account,
attempt_status="failed",
attempt_message="等待验证码超时",
)
counters, _ = self._settle([(account, task)], max_attempts=2)
self.session.refresh(account)
assert counters["manual"] == 1 and counters["retry"] == 0
assert account.relogin_next_at is None
def test_settlement_never_touches_cookie(self):
"""结算只改账号的 ``relogin_*``,绝不碰账号持有的 CK。"""
account = self.make_account("keepck", cookie="acf_uid=1")
task = self.make_attempt(
account, attempt_status="failed", attempt_message="极验验证失败: slide"
)
before = (account.cookie, account.status)
self._settle([(account, task)])
self.session.refresh(account)
assert (account.cookie, account.status) == before
assert account.cookie == "acf_uid=1"
# 顺带确认结算确实跑了:分型写上了,而 CK 仍原封不动。
assert account.relogin_failure_kind == "captcha"
class TestManualQueueApi(_Base):
"""待人工处理队列:只列自动重登救不了的,按原因分组。"""
def _queue(self):
from web.backend.routers import automation as api
return api.manual_queue(limit=20, db=self.session, current=self.admin)
def test_groups_manual_kinds_only(self):
captcha = self.make_account(
"cap",
relogin_failure_kind="captcha",
relogin_attempts=1,
relogin_last_error="极验验证失败: slide",
)
self.make_attempt(
captcha, attempt_status="failed", attempt_message="极验验证失败: slide"
)
cred = self.make_account(
"cred",
relogin_failure_kind="credential",
relogin_attempts=1,
relogin_last_error="账号缺少密码或邮箱",
)
self.make_attempt(
cred, attempt_status="failed", attempt_message="账号缺少密码或邮箱"
)
# 可自动重试的不该出现在人工队列里
retryable = self.make_account(
"retryable",
relogin_failure_kind="email_code",
relogin_attempts=1,
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
relogin_last_error="等待验证码超时",
)
self.make_attempt(
retryable, attempt_status="failed", attempt_message="等待验证码超时"
)
payload = self._queue()
kinds = [group["kind"] for group in payload["groups"]]
assert sorted(kinds) == ["captcha", "credential"]
assert payload["total"] == 2
captcha_group = next(g for g in payload["groups"] if g["kind"] == "captcha")
assert captcha_group["count"] == 1
assert captcha_group["samples"][0]["account_id"] == captcha.id
assert "极验" in captcha_group["label"]
def test_exhausted_retries_show_up_as_manual(self):
"""次数用尽的(next_run_at 已清空)也要冒出来,否则会静默卡住。"""
used = self.make_account(
"used",
relogin_failure_kind="email_code",
relogin_attempts=2,
relogin_next_at=None,
relogin_last_error="等待验证码超时",
)
self.make_attempt(
used, attempt_status="failed", attempt_message="等待验证码超时"
)
payload = self._queue()
assert payload["total"] == 1
assert "重试次数用尽" in payload["groups"][0]["label"]
def test_sample_last_error_comes_from_account(self):
"""样本里的 last_error 取账号的 ``relogin_last_error``,不回退到尝试文案。"""
account = self.make_account(
"cap",
relogin_failure_kind="captcha",
relogin_attempts=1,
relogin_last_error="账号侧记录的失败原因",
)
self.make_attempt(
account,
attempt_status="failed",
attempt_message="这是尝试记录里的文案,不该被当成账号的最后错误",
)
payload = self._queue()
assert payload["total"] == 1
assert (
payload["groups"][0]["samples"][0]["last_error"] == "账号侧记录的失败原因"
)
def test_empty_queue(self):
payload = self._queue()
assert payload["total"] == 0 and payload["groups"] == []
class TestLinkageWithHealthJob(_Base):
"""两个作业是上下游:jobs 接口要暴露「当前有多少条等着自动重登」。
页面靠这个数字把巡检 → 重登的关系显性化;没有它,两个开关看起来像互不相干。
解耦后 ``count_pending_relogin`` 按**账号**计数。
"""
def test_pending_relogin_counts_only_actionable_invalid(self):
from web.backend.routers import automation as api
ready = self.make_account("ready")
self.make_account("valid", ck_check_status="valid")
self.make_account("nocookie", cookie="")
self.make_account("optedout", auto_managed=False)
payload = api.list_jobs(db=self.session, current=self.admin)
# 只有 ready 同时满足:invalid + 有 CK + 允许自动管理
assert payload["pending_relogin"] == 1
assert any(job["key"] == ck_relogin.JOB_KEY for job in payload["jobs"])
assert ready.id is not None
def test_pending_count_is_not_capped_by_batch_size(self):
"""积压数不能被批次上限截断。
一开始复用了作业的候选查询来取 count,而那个查询带 LIMIT batch_size,
页面上于是恒显示 10(批次上限),完全看不出真实积压 —— 线上就是这样
显示了「待重登 10」而实际有一千多。
"""
from web.backend.routers import automation as api
for index in range(25):
self.make_account(f"acc{index}")
payload = api.list_jobs(db=self.session, current=self.admin)
relogin = next(j for j in payload["jobs"] if j["key"] == ck_relogin.JOB_KEY)
assert relogin["batch_size"] == 10
assert payload["pending_relogin"] == 25 # 不被 limit 10 截断
class TestSettlementVisibility(_Base):
"""回归:结算必须用**新事务**读回 runner 提交的结果。
MySQL 默认隔离级别是 REPEATABLE READ:在同一个事务里重新查询只会看到事务
开始时的快照。第一版实现复用调度器传进来的 session 读结果,而那 10 条重登
**实际全部成功**,结算却按失败写回(加了退避、2 条还被标成极验进人工队列)。
SQLite 的读一致性不同,这条在 SQLite 上复现不出来 —— 属于「测试跑在 SQLite
就漏掉」的典型,现在测试跑在 MySQL 上才守得住。
解耦后本轮记录靠 ``LoginTask.attempt_batch_id`` 找回,退避进度落在账号上。
"""
def test_settlement_sees_other_session_commit(self):
account = self.make_account(
"acc",
relogin_attempts=1,
relogin_failure_kind="email_code",
relogin_last_error="上一次的失败",
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
)
task = self.make_attempt(
account, attempt_status="pending", attempt_batch_id="batch-visibility"
)
# 调用方会话先开事务(模拟调度器挑完候选后的状态)。
stale = make_test_session(self.engine)
assert (
stale.query(LoginTask).filter(LoginTask.id == task.id).one().attempt_status
== "pending"
)
# runner 在另一个会话里写入成功并提交。
runner = make_test_session(self.engine)
runner.query(LoginTask).filter(
LoginTask.id == task.id
).one().attempt_status = "success"
runner.commit()
runner.close()
# 旧实现在 stale 那个事务里读,只会看到 pending —— 于是把成功写成失败。
assert (
stale.query(LoginTask).filter(LoginTask.id == task.id).one().attempt_status
== "pending"
), "REPEATABLE READ 下同一事务读不到别人的提交,这正是当初踩的坑"
counters = ck_relogin.settle_fresh_results(
"batch-visibility", now=datetime.now(UTC), max_attempts=2
)
assert counters["valid"] == 1 and counters["invalid"] == 0
# 结算写在自己的事务里。这里必须再用**一个全新会话**读回:当前测试会话的
# 事务快照在上面读 ``task.id``(commit 后属性已过期,取用即触发刷新)时
# 就已经开了,复用它会看到结算前的旧值 —— 正是这条用例要守的坑。
checker = make_test_session(self.engine)
settled = checker.get(Account, account.id)
assert settled.relogin_failure_kind == ""
assert settled.relogin_attempts == 0
assert settled.relogin_next_at is None
checker.close()
stale.close()
def test_settlement_marks_real_success_as_valid(self):
"""结算看到 success 就必须清零退避,不能写成失败。"""
account = self.make_account(
"acc",
relogin_attempts=1,
relogin_failure_kind="email_code",
relogin_last_error="上一次的失败",
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
)
task = self.make_attempt(account, attempt_status="success")
now = datetime.now(UTC)
counters = ck_relogin.settle_results(
self.session,
{account.id: (account, task)},
[account.id],
now=now,
max_attempts=2,
)
assert counters["valid"] == 1 and counters["invalid"] == 0
assert account.relogin_failure_kind == ""
assert account.relogin_next_at is None