"""回归:替换 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()