test_web_routes.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """Tests for web.routes helpers and endpoint guards."""
  2. import sys
  3. import os
  4. import threading
  5. import time
  6. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  7. from types import SimpleNamespace
  8. from unittest.mock import MagicMock
  9. import numpy as np
  10. import pytest
  11. from fastapi import HTTPException
  12. from fastapi.responses import JSONResponse
  13. from pydantic import ValidationError
  14. import web.routes as routes
  15. import web.state as _web_state_module
  16. from web.routes import (
  17. _get_state,
  18. _resolve_panorama_path,
  19. AddPointPayload,
  20. PreviewPayload,
  21. api_panorama,
  22. api_preview,
  23. api_start_scan,
  24. api_live,
  25. )
  26. @pytest.fixture(autouse=True)
  27. def reset_web_state(monkeypatch):
  28. """Isolate the module-level web_state between tests."""
  29. monkeypatch.setattr(_web_state_module, "web_state", None)
  30. def test_get_state_raises_503_when_not_initialized():
  31. _web_state_module.web_state = None
  32. with pytest.raises(HTTPException) as exc_info:
  33. _get_state()
  34. assert exc_info.value.status_code == 503
  35. assert "not initialized" in exc_info.value.detail.lower()
  36. def test_resolve_panorama_path_returns_valid_path(tmp_path, monkeypatch):
  37. base = tmp_path / "panorama_data"
  38. (base / "panorama").mkdir(parents=True)
  39. img = base / "panorama" / "scan.jpg"
  40. img.write_bytes(b"")
  41. monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
  42. resolved = _resolve_panorama_path("panorama/scan.jpg")
  43. assert resolved == img.resolve()
  44. def test_resolve_panorama_path_rejects_escape(tmp_path, monkeypatch):
  45. base = tmp_path / "panorama_data"
  46. base.mkdir()
  47. monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
  48. with pytest.raises(HTTPException) as exc_info:
  49. _resolve_panorama_path("../evil.jpg")
  50. assert exc_info.value.status_code == 400
  51. assert "Invalid panorama path" in exc_info.value.detail
  52. def test_api_panorama_rejects_traversal_from_store(tmp_path, monkeypatch):
  53. base = tmp_path / "data"
  54. base.mkdir()
  55. monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
  56. scan_store = SimpleNamespace(
  57. get_group=lambda _gid: {"panorama": {"equirectangular": "../secret.txt"}}
  58. )
  59. _web_state_module.web_state = SimpleNamespace(
  60. scan_store=scan_store,
  61. group_state=None,
  62. scanners={},
  63. schedulers={},
  64. stream_manager=None,
  65. )
  66. with pytest.raises(HTTPException) as exc_info:
  67. api_panorama("g1")
  68. assert exc_info.value.status_code == 400
  69. def test_start_scan_returns_202_and_launches_thread():
  70. started = threading.Event()
  71. scanner = MagicMock()
  72. def run_with_event(*args, **kwargs):
  73. started.set()
  74. return {
  75. "samples": [],
  76. "panorama_path": None,
  77. "config": {},
  78. }
  79. scanner.run.side_effect = run_with_event
  80. group_data = {"polling_state": "idle"}
  81. class MockGroupState:
  82. def get(self, _gid):
  83. return dict(group_data)
  84. def update(self, _gid, key, value):
  85. group_data[key] = value
  86. def compare_and_update(self, _gid, key, expected, new_value):
  87. if group_data.get(key) == expected:
  88. group_data[key] = new_value
  89. return True
  90. return False
  91. state = SimpleNamespace(
  92. group_state=MockGroupState(),
  93. scan_store=SimpleNamespace(
  94. set_samples=lambda *_args, **_kwargs: None,
  95. set_panorama=lambda *_args, **_kwargs: None,
  96. set_scan_config=lambda *_args, **_kwargs: None,
  97. ),
  98. scanners={"g1": scanner},
  99. schedulers={},
  100. stream_manager=None,
  101. )
  102. _web_state_module.web_state = state
  103. resp = api_start_scan("g1")
  104. assert isinstance(resp, JSONResponse)
  105. assert resp.status_code == 202
  106. assert resp.body is not None
  107. assert b"Scan started" in resp.body
  108. deadline = time.time() + 1.0
  109. while time.time() < deadline and not started.is_set():
  110. time.sleep(0.01)
  111. started.wait(timeout=0)
  112. scanner.run.assert_called_once()
  113. def test_start_scan_rejects_concurrent_scan():
  114. group_data = {"polling_state": "scanning"}
  115. class MockGroupState:
  116. def get(self, _gid):
  117. return dict(group_data)
  118. def compare_and_update(self, _gid, key, expected, new_value):
  119. if group_data.get(key) == expected:
  120. group_data[key] = new_value
  121. return True
  122. return False
  123. state = SimpleNamespace(
  124. group_state=MockGroupState(),
  125. scanners={"g1": None},
  126. schedulers={},
  127. stream_manager=None,
  128. scan_store=None,
  129. )
  130. _web_state_module.web_state = state
  131. with pytest.raises(HTTPException) as exc_info:
  132. api_start_scan("g1")
  133. assert exc_info.value.status_code == 409
  134. assert "already in progress" in exc_info.value.detail.lower()
  135. def test_start_scan_returns_404_when_group_not_found():
  136. class MockGroupState:
  137. def get(self, _gid):
  138. return {}
  139. def compare_and_update(self, _gid, _key, _expected, _new_value):
  140. return False
  141. state = SimpleNamespace(
  142. group_state=MockGroupState(),
  143. scanners={},
  144. schedulers={},
  145. stream_manager=None,
  146. scan_store=None,
  147. )
  148. _web_state_module.web_state = state
  149. with pytest.raises(HTTPException) as exc_info:
  150. api_start_scan("missing")
  151. assert exc_info.value.status_code == 404
  152. assert "Group not found" in exc_info.value.detail
  153. def test_api_live_rejects_invalid_camera_type():
  154. _web_state_module.web_state = SimpleNamespace(stream_manager=MagicMock())
  155. with pytest.raises(HTTPException) as exc_info:
  156. api_live("front_door", "g1")
  157. assert exc_info.value.status_code == 400
  158. assert "Invalid camera type" in exc_info.value.detail
  159. @pytest.mark.parametrize(
  160. "payload, should_raise",
  161. [
  162. ({"pan": 10, "tilt": 5}, False),
  163. ({"pan": 400, "tilt": 0}, True),
  164. ({"pan": 0, "tilt": 100}, True),
  165. ({"pan": 0, "tilt": 0, "zoom": 0}, True),
  166. ({"pan": 0, "tilt": 0, "dwell_time": -1}, True),
  167. ],
  168. )
  169. def test_add_point_payload_validation(payload, should_raise):
  170. if should_raise:
  171. with pytest.raises(ValidationError):
  172. AddPointPayload(**payload)
  173. else:
  174. model = AddPointPayload(**payload)
  175. assert model.pan == payload["pan"]
  176. assert model.tilt == payload["tilt"]
  177. def test_api_preview_uses_fixed_settle_and_drain(monkeypatch):
  178. """preview 不依赖过期的 ptz_position 计算等待/排空,防止拍到旧帧。"""
  179. sleeps = []
  180. def fake_sleep(duration):
  181. sleeps.append(duration)
  182. monkeypatch.setattr(time, "sleep", fake_sleep)
  183. frame = np.zeros((100, 100, 3), dtype=np.uint8)
  184. stream = MagicMock()
  185. stream.get_frame.return_value = frame
  186. stream_manager = MagicMock()
  187. stream_manager.get.return_value = stream
  188. ptz = MagicMock()
  189. ptz.goto_exact_position.return_value = True
  190. # 故意让上一次目标与本次完全相同;旧实现会因此缩短等待时间
  191. group_data = {"ptz_position": {"pan": 30.0, "tilt": 0.0, "zoom": 1}}
  192. class MockGroupState:
  193. def get(self, _gid):
  194. return dict(group_data)
  195. def update(self, _gid, key, value):
  196. group_data[key] = value
  197. state = SimpleNamespace(
  198. group_state=MockGroupState(),
  199. ptz_cameras={"g1": ptz},
  200. stream_manager=stream_manager,
  201. )
  202. _web_state_module.web_state = state
  203. monkeypatch.setattr(routes.cv2, "imwrite", lambda _path, _img: True)
  204. payload = PreviewPayload(pan=30.0, tilt=0.0, zoom=1)
  205. resp = api_preview("g1", payload)
  206. assert resp["position"] == {"pan": 30.0, "tilt": 0.0, "zoom": 1}
  207. ptz.goto_exact_position.assert_called_once_with(30.0, 0.0, 1)
  208. # 应等待完整的稳定时间,并执行固定次数的排空
  209. assert sleeps[0] == pytest.approx(3.0, abs=0.01)
  210. assert sum(sleeps[1:]) == pytest.approx(20 * 0.15, abs=0.01)
  211. assert stream.get_frame.call_count >= 20