2026-11-05 05:47:13 -05:00
|
|
|
"""运行控制以实际心跳呈现状态,并保护并发变更。"""
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
from threading import Event
|
|
|
|
|
from unittest.mock import MagicMock
|
2026-11-05 20:42:07 -05:00
|
|
|
from urllib.error import HTTPError
|
2026-11-05 05:47:13 -05:00
|
|
|
|
|
|
|
|
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 地址"
|
|
|
|
|
|
|
|
|
|
|
2026-11-05 20:42:07 -05:00
|
|
|
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+ 连接配置未就绪"
|
|
|
|
|
|
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
@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
|
2026-11-05 20:42:07 -05:00
|
|
|
monkeypatch.setattr(supervisor, "_open_health", lambda *_args, **_kwargs: response)
|
2026-11-05 05:47:13 -05:00
|
|
|
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}}'
|
2026-11-05 20:42:07 -05:00
|
|
|
monkeypatch.setattr(supervisor, "_open_health", lambda *_args, **_kwargs: response)
|
2026-11-05 05:47:13 -05:00
|
|
|
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()
|