test_mock_api_lifecycle.py 2.7 KB

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