PayrollManagementSystem/backend/tests/unit/test_tplus_runtime_control.py

200 lines
8.4 KiB
Python
Raw Normal View History

"""运行控制以实际心跳呈现状态,并保护并发变更。"""
from datetime import datetime, timedelta
from threading import Event
from unittest.mock import MagicMock
from urllib.error import HTTPError
import pytest
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.core.exceptions import ConflictError, ForbiddenError
from app.models import OperationLog, TPlusRuntimeControl, User
from app.schemas.tplus import TPlusRuntimeUpdate
from app.services.tplus import runtime_control, supervisor, task_service
from tests.unit.test_tplus_workbench import work_session as work_session
def test_启动请求不冒充运行且冲突受保护(work_session):
session = work_session
session.add(TPlusRuntimeControl(id=1))
session.commit()
actor = task_service.TaskActor(session.get(User, 1))
payload = TPlusRuntimeUpdate(desired_state="RUNNING", expected_version=0)
status = runtime_control.save(session, payload, actor)
assert status["desired_state"] == "RUNNING"
assert status["actual_state"] == "OFFLINE"
assert status["version"] == 1
assert session.scalar(select(OperationLog).where(OperationLog.action == "修改读取服务状态"))
with pytest.raises(ConflictError):
runtime_control.save(session, payload, actor)
assert supervisor.may_claim(session)
session.rollback()
runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=1), actor)
assert not supervisor.may_claim(session)
def test_过期心跳显示离线且权限拒绝(work_session, monkeypatch):
session = work_session
now = datetime(2026, 11, 5, 10)
session.add(TPlusRuntimeControl(id=1, desired_state="RUNNING", actual_state="RUNNING",
worker_phase="RUNNING", heartbeat_at=now - timedelta(seconds=91)))
session.commit()
assert runtime_control.overview(session, now=now)["actual_state"] == "OFFLINE"
def denied(*_args):
raise ForbiddenError()
monkeypatch.setattr(runtime_control, "require_permission", denied)
with pytest.raises(ForbiddenError):
runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=0),
task_service.TaskActor(session.get(User, 1)))
assert session.get(TPlusRuntimeControl, 1).desired_state == "RUNNING"
def test_健康检查拒绝外部地址():
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32,
t_plus_supervisor_health_url="http://example.com/api/health")
assert supervisor.health_error(settings) == "后端健康检查地址必须是本机 HTTP 地址"
def test_健康检查处理器拒绝外部重定向():
request = MagicMock()
response = MagicMock()
assert supervisor._LocalOnlyRedirectHandler().redirect_request(
request, response, 302, "Found", {}, "http://example.com/api/health"
) is None
def test_健康检查重定向到外部地址时拒绝(monkeypatch):
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(
supervisor,
"_open_health",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
HTTPError("http://127.0.0.1/api/health", 302, "Found", {}, None)
),
)
assert supervisor.health_error(settings) == "系统健康检查或 T+ 连接配置未就绪"
@pytest.mark.parametrize("body", [b'[]', b'null', b'{"data":null}', b'{"data":[]}', b'{"data":"bad"}'])
def test_健康检查结构异常时返回未就绪(monkeypatch, body):
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32)
response = MagicMock()
response.__enter__.return_value.read.return_value = body
monkeypatch.setattr(supervisor, "_open_health", lambda *_args, **_kwargs: response)
assert supervisor.health_error(settings) == "系统或依赖尚未就绪"
@pytest.mark.parametrize("connected", [True, False])
def test_健康检查实际探测来源连接且不泄露错误(monkeypatch, connected):
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True,
t_plus_database="source", t_plus_user="reader",
t_plus_odbc_driver="test-driver")
response = MagicMock()
response.__enter__.return_value.read.return_value = b'{"data":{"app":true,"db":true,"redis":true}}'
monkeypatch.setattr(supervisor, "_open_health", lambda *_args, **_kwargs: response)
import pyodbc
monkeypatch.setattr(pyodbc, "drivers", lambda: ["test-driver"])
engine = MagicMock()
if not connected:
engine.connect.side_effect = SQLAlchemyError("secret-connection-string")
monkeypatch.setattr(supervisor, "create_engine", lambda *_args, **_kwargs: engine)
result = supervisor.health_error(settings)
assert result is None if connected else result == "T+ 数据库连接不可用,请检查连接配置"
if connected:
connection = engine.connect.return_value.__enter__.return_value
assert connection.connection.driver_connection.timeout == settings.t_plus_query_timeout
connection.exec_driver_sql.assert_called_once_with("SELECT 1")
engine.dispose.assert_called_once()
def test_来源不可用时托管器不排队或领取任务(monkeypatch):
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.return_value.scalar.return_value = 1
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
session = factory.return_value.__enter__.return_value
session.scalar.return_value.desired_state = "RUNNING"
stopped = Event()
def unavailable(_settings):
stopped.set()
return "T+ 数据库连接不可用,请检查连接配置"
maintenance, worker = MagicMock(), MagicMock()
monkeypatch.setattr(supervisor, "health_error", unavailable)
monkeypatch.setattr(supervisor, "tick", maintenance)
monkeypatch.setattr(supervisor, "run_once", worker)
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
maintenance.assert_not_called()
worker.assert_not_called()
def test_托管检查异常记录类型但不泄露内容(caplog):
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("secret-value"),
MagicMock(scalar=lambda: 1)]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
assert supervisor.run(factory, MagicMock()) == 1
assert "RuntimeError" in caplog.text
assert "secret-value" not in caplog.text
@pytest.mark.parametrize("release", [None, 0])
def test_托管锁释放失败时使连接失效(release):
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1),
MagicMock(scalar=lambda: release)]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
stopped = Event()
stopped.set()
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
connection.invalidate.assert_called_once()
connection.close.assert_called_once()
def test_停止状态写入失败仍释放托管锁():
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), MagicMock(scalar=lambda: 1)]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
factory.return_value.__enter__.side_effect = RuntimeError("数据库暂不可用")
stopped = Event()
stopped.set()
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
assert connection.execute.call_count == 2
connection.invalidate.assert_not_called()
connection.close.assert_called_once()
def test_释放托管锁抛错时使连接失效():
connection = MagicMock()
connection.dialect.name = "mysql"
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("连接异常")]
factory = MagicMock()
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
stopped = Event()
stopped.set()
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
connection.invalidate.assert_called_once()
connection.close.assert_called_once()