Files
live-hub-py/tests/test_login_attempt_isolation.py
T
yml2213 c3fd299f3a test(db): 测试改用真实 MySQL,方言差异不再漏到生产
生产是 MySQL 8、测试是 SQLite,两者的方言差异在本地完全测不出来。今天为此
付出了两次代价:

1. `ORDER BY ck_checked_at ASC NULLS FIRST` 在 SQLite 上完全正常,本地 258 条
   全绿,一部署到生产第一次巡检就 1064 语法错误(MySQL 不支持 NULLS FIRST);
2. P1b 自动重登的结算复用调度器事务读取,在 SQLite 下看不出问题,但在 MySQL 的
   REPEATABLE READ 下读到的是批次开始前的快照 —— 10 条实际成功的重登全部被记成
   失败。SQLite 的读一致性与 MySQL 不同,这类问题跑一万遍也复现不出来。

改动:
- 新增 tests/support/db.py:测试专用引擎/会话工具,连接串从 .env 读取,
  默认指向独立的 douyu_login_test 库;
- 新增 tests/conftest.py:会话级建好表结构,每个用例前后清空所有表做隔离;
- 根 conftest.py 接管连接串设置:它在 pytest 收集阶段最先执行,在这里把
  DATABASE_URL 指向测试库,应用模块首次 import 拿到就是 MySQL,不依赖任何
  import 顺序(原先这里是 sqlite:// 兜底,改造后会变成「先建 SQLite engine
  再打补丁换成 MySQL」,顺序一变就静默跑错库);
- 21 个测试文件的 setup 样板统一改用共享测试引擎;
- 两个迁移测试改为在临时库 douyu_login_mig_<随机> 上跑完整 alembic upgrade head,
  跑完 drop —— 迁移脚本本身也终于在 MySQL 上被验证;
- dev.sh 与 README 的过时说明一并更新(原先写着「不需要 MySQL / .env 配置」;
  「测试各自建 SQLite 内存库」)。

安全与易用:
- 测试库名与开发库同名时**直接拒绝运行**(clear_tables 会 DELETE 所有表,
  一旦 TEST_MYSQL_DATABASE 被指到 douyu_login 就是数据灾难);
- 连不上 MySQL 时给出可操作提示(先 ./dev.sh 起库 / 用 TEST_MYSQL_DATABASE 覆盖),
  而不是抛一段 SQLAlchemy 原始堆栈。

验收:
- 全量 301 条通过,连跑两次结果一致(隔离有效),耗时约 12.5 秒(原 SQLite 约 3.6 秒);
- 单文件独立运行通过(不依赖全量执行顺序);
- `grep -rn sqlite tests/ conftest.py` 已无实际用法;
- **方言确实生效**:临时把一条查询改回 `NULLS FIRST`,13 条测试立刻变红(含运行期
  失败),还原即恢复;这条由 test_generated_sql_is_mysql_compatible 长期守着。

代价(已在 README 与规划附录 C.2 记明):跑测试前必须先有 MySQL,pre-push 钩子
同理;CI 需要 service container。
2026-10-02 21:22:08 +08:00

214 lines
7.4 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.
"""回归:替换 CK 必须是最后一步,只有登录成功才会写回记录。
事故背景:批次启动时会把复用的成功记录直接改成 pending(旧 CK 仍保留),
批次被停止或失败后没有任何地方把它改回成功,账号于是从工作台消失。
现在尝试状态独立存放在 attempt_*,status/cookie/message 只在登录成功时
更新,所以停止、失败、进程重启都不可能破坏账号已有的 CK。
"""
from types import SimpleNamespace
from sqlalchemy.orm import sessionmaker
import web.backend.database as database_module
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, LoginTask, User
from web.backend.routers.login import list_tasks
from web.backend.services import login_service
class _FakeLogin:
"""替身登录器:不发网络请求,结果由类属性控制。"""
success = True
cookie = "new-cookie"
message = "登录成功"
code = None
calls = 0
def __init__(self, *_args, **_kwargs):
pass
def login(self):
type(self).calls += 1
return SimpleNamespace(
success=type(self).success,
cookie=type(self).cookie if type(self).success else "",
message=type(self).message,
code=type(self).code,
)
def check_account(self):
return self.login()
class TestLoginAttemptIsolation:
def setup_method(self):
self.engine = get_test_engine()
database_module.SessionLocal.configure(bind=self.engine)
self.session = make_test_session(self.engine)
self.user = User(username="admin", password_hash="hash", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="远行者103",
password="password",
email="yuanxingzhe@example.com",
email_password="mail-password",
)
self.session.add(self.account)
self.session.flush()
self.task = LoginTask(
batch_id="old-batch",
account_id=self.account.id,
created_by=self.user.id,
status="success",
cookie="old-cookie",
message="登录成功",
)
self.session.add(self.task)
self.session.commit()
self.task_id = self.task.id
_FakeLogin.success = True
_FakeLogin.cookie = "new-cookie"
_FakeLogin.message = "登录成功"
_FakeLogin.calls = 0
def teardown_method(self):
self.session.close()
def _run_batch(self, monkeypatch, *, mode="login", account_ids=None, stop=False):
monkeypatch.setattr(login_service, "DouyuLogin", _FakeLogin)
# 批次结束时 runner 会关掉自己的会话,因此单独给它一个。
runner_db = sessionmaker(bind=self.engine)()
runner = login_service.LoginBatchRunner(
db=runner_db,
account_ids=account_ids if account_ids is not None else [self.account.id],
created_by=self.user.id,
creator_permissions=["login:view_all"],
proxy_config=None,
concurrency=1,
mode=mode,
)
if stop:
runner.stop()
runner.run()
return runner
def _fresh(self, task_id: int) -> LoginTask:
session = sessionmaker(bind=self.engine)()
try:
return session.query(LoginTask).filter(LoginTask.id == task_id).one()
finally:
session.close()
def _fresh_user(self, session) -> User:
return session.query(User).filter(User.id == self.user.id).one()
def test_failed_attempt_keeps_existing_cookie(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch)
task = self._fresh(self.task_id)
# 记录持有 CK 的字段完全没被碰过
assert task.status == "success"
assert task.cookie == "old-cookie"
assert task.message == "登录成功"
assert task.batch_id == "old-batch"
# 失败只体现在尝试字段上
assert task.attempt_status == "failed"
assert task.attempt_message == "账号密码错误"
assert task.attempt_at is not None
def test_failed_attempt_reuses_the_cookie_row(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch)
session = sessionmaker(bind=self.engine)()
try:
rows = (
session.query(LoginTask)
.filter(LoginTask.account_id == self.account.id)
.all()
)
finally:
session.close()
assert [row.id for row in rows] == [self.task_id]
def test_successful_attempt_replaces_cookie(self, monkeypatch):
self._run_batch(monkeypatch)
task = self._fresh(self.task_id)
assert task.status == "success"
assert task.cookie == "new-cookie"
assert task.message == "登录成功"
assert task.attempt_status == "success"
assert task.attempt_at is not None
assert task.batch_id != "old-batch"
def test_stopped_batch_keeps_existing_cookie(self, monkeypatch):
self._run_batch(monkeypatch, stop=True)
task = self._fresh(self.task_id)
assert _FakeLogin.calls == 0
assert task.cookie == "old-cookie"
assert task.status == "success"
assert task.attempt_status == "failed"
assert "旧 Cookie 已保留" in task.attempt_message
def test_list_tasks_reports_attempt_state_without_losing_ck_row(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
runner = self._run_batch(monkeypatch)
read_db = sessionmaker(bind=self.engine)()
try:
rows = list_tasks(
batch_id=runner.batch_id, db=read_db, current=self._fresh_user(read_db)
)
finally:
read_db.close()
assert len(rows) == 1
row = rows[0]
# 页面看到的是「这次尝试怎么样了」,而记录仍然持有 CK
assert row.status == "failed"
assert row.message == "账号密码错误"
assert row.batch_id == runner.batch_id
assert self._fresh(self.task_id).cookie == "old-cookie"
def test_account_without_cookie_still_reports_failure(self, monkeypatch):
"""首次登录失败的账号没有 CK 需要保护,保持原来的失败语义。"""
fresh_account = Account(
username="no-cookie",
password="password",
email="nocookie@example.com",
email_password="mail-password",
)
self.session.add(fresh_account)
self.session.commit()
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch, account_ids=[fresh_account.id])
session = sessionmaker(bind=self.engine)()
try:
task = (
session.query(LoginTask)
.filter(LoginTask.account_id == fresh_account.id)
.one()
)
assert task.status == "failed"
assert task.message == "账号密码错误"
assert task.attempt_status == "failed"
assert task.cookie == ""
finally:
session.close()