"""Mock API 内嵌启停的真实端口生命周期回归(缺陷回归守卫)。 背景:uvicorn 0.30+ 的 Server 已无 startup_error 属性,旧实现访问它会抛 AttributeError, 使 POST /api/mock-api/start 恒定返回 500(而服务其实已启动),端口被占用时也无法如实报错。 既有测试全部走进程内 TestClient,覆盖不到真实绑定路径——本文件用真实端口补齐该盲区。 """ from __future__ import annotations import asyncio import socket import pytest import app # noqa: E402 conftest 已把模拟器根目录加入 sys.path def _free_port() -> int: """取一个当前空闲的端口(绑定 0 由 OS 分配后立即释放)。""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] @pytest.fixture() def mock_port(monkeypatch): """把 mockApiPort 指向空闲端口;启停在用例内完成,夹具只复位全局句柄。""" port = _free_port() monkeypatch.setitem(app.CONFIG, "mockApiPort", port) yield port app.MOCK_HANDLE.server = None app.MOCK_HANDLE.task = None app.MOCK_HANDLE.last_error = "" def test_start_binds_real_port_and_is_idempotent(mock_port): """启动成功须返回 running=true、端口真实可连通、重复调用幂等,且不抛异常(不再 500)。""" async def scenario(): result = await app._start_mock_api() try: assert result["running"] is True, result assert result["port"] == mock_port assert app._mock_running() is True assert app.MOCK_HANDLE.last_error == "" # 端口必须真实可连通,不能只是内部状态为真 with socket.create_connection(("127.0.0.1", mock_port), timeout=2): pass again = await app._start_mock_api() assert again.get("alreadyRunning") is True, again finally: await app._stop_mock_api() asyncio.run(scenario()) assert app._mock_running() is False def test_start_on_occupied_port_reports_error_not_raise(mock_port): """端口被占用须返回 {running:false, error} 并写入 lastError,而不是抛 AttributeError。""" blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM) blocker.bind(("127.0.0.1", mock_port)) blocker.listen(1) async def scenario(): try: return await app._start_mock_api() finally: blocker.close() result = asyncio.run(scenario()) assert result["running"] is False, result assert str(mock_port) in result["error"] assert app.MOCK_HANDLE.last_error # /api/status 的 lastError 诊断字段恢复有效 assert app._mock_running() is False