test_routes.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import sys
  2. import os
  3. import threading
  4. import time
  5. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  6. from unittest.mock import MagicMock
  7. import numpy as np
  8. import pytest
  9. from fastapi.testclient import TestClient
  10. from app import create_app
  11. import web.state as _web_state_module
  12. @pytest.fixture
  13. def client():
  14. app = create_app(test_mode=True)
  15. return TestClient(app)
  16. @pytest.fixture
  17. def client_with_mocks():
  18. app = create_app(test_mode=True)
  19. client = TestClient(app)
  20. scanner = MagicMock()
  21. scanner.run.return_value = {
  22. "samples": [],
  23. "panorama_path": None,
  24. "config": {},
  25. }
  26. scheduler = MagicMock()
  27. ptz = MagicMock()
  28. ptz_stream = MagicMock()
  29. ptz_stream.get_frame.return_value = np.zeros((100, 100, 3), dtype=np.uint8)
  30. stream_manager = MagicMock()
  31. stream_manager.get.return_value = ptz_stream
  32. _web_state_module.web_state.scanners["group_1"] = scanner
  33. _web_state_module.web_state.schedulers["group_1"] = scheduler
  34. _web_state_module.web_state.ptz_cameras["group_1"] = ptz
  35. _web_state_module.web_state.stream_manager = stream_manager
  36. return client, scanner, scheduler, ptz, ptz_stream
  37. def test_status_endpoint(client):
  38. response = client.get("/api/status")
  39. assert response.status_code == 200
  40. assert "groups" in response.json()
  41. def test_add_and_list_points(client):
  42. response = client.post("/api/points/group_1", json={
  43. "pan": 30.0,
  44. "tilt": 0.0,
  45. "zoom": 1,
  46. "dwell_time": 3.0,
  47. })
  48. assert response.status_code == 200
  49. data = response.json()
  50. assert data["pan"] == 30.0
  51. response = client.get("/api/points/group_1")
  52. assert response.status_code == 200
  53. points = response.json()["points"]
  54. assert len(points) == 1
  55. def test_add_point_validation(client):
  56. response = client.post("/api/points/group_1", json={
  57. "pan": 400.0,
  58. "tilt": 0.0,
  59. "zoom": 1,
  60. "dwell_time": 3.0,
  61. })
  62. assert response.status_code == 422
  63. def test_delete_point(client):
  64. response = client.post("/api/points/group_1", json={
  65. "pan": 60.0,
  66. "tilt": 10.0,
  67. "zoom": 1,
  68. "dwell_time": 2.0,
  69. })
  70. point_id = response.json()["id"]
  71. response = client.delete(f"/api/points/group_1/{point_id}")
  72. assert response.status_code == 200
  73. response = client.get("/api/points/group_1")
  74. assert len(response.json()["points"]) == 0
  75. def test_static_index(client):
  76. response = client.get("/")
  77. assert response.status_code == 200
  78. assert "text/html" in response.headers["content-type"]
  79. def test_panorama_not_found(client):
  80. response = client.get("/api/panorama/group_1")
  81. assert response.status_code == 404
  82. def test_scan_start_returns_202(client_with_mocks):
  83. client, scanner, _, _, _ = client_with_mocks
  84. started = threading.Event()
  85. def run_with_event(*args, **kwargs):
  86. started.set()
  87. return scanner.run.return_value
  88. scanner.run.side_effect = run_with_event
  89. response = client.post("/api/scan/group_1")
  90. assert response.status_code == 202
  91. assert response.json()["group_id"] == "group_1"
  92. deadline = time.time() + 2.0
  93. while time.time() < deadline and not started.is_set():
  94. time.sleep(0.01)
  95. assert started.is_set(), "Scanner run() was not invoked"
  96. def test_scan_uses_full_vertical_range(client_with_mocks):
  97. """360° 扫描应覆盖 -90° 到 +90° 的完整上下范围。"""
  98. client, scanner, _, _, _ = client_with_mocks
  99. started = threading.Event()
  100. def run_with_event(*args, **kwargs):
  101. started.set()
  102. return scanner.run.return_value
  103. scanner.run.side_effect = run_with_event
  104. response = client.post("/api/scan/group_1")
  105. assert response.status_code == 202
  106. deadline = time.time() + 2.0
  107. while time.time() < deadline and not started.is_set():
  108. time.sleep(0.01)
  109. assert started.is_set(), "Scanner run() was not invoked"
  110. _, kwargs = scanner.run.call_args
  111. tilt_layers = kwargs.get("tilt_layers", ())
  112. assert min(tilt_layers) <= -90
  113. assert max(tilt_layers) >= 90
  114. def test_poll_start_and_stop(client_with_mocks):
  115. client, _, scheduler, _, _ = client_with_mocks
  116. response = client.post("/api/poll/group_1/start")
  117. assert response.status_code == 200
  118. scheduler.start.assert_called_once()
  119. response = client.post("/api/poll/group_1/stop")
  120. assert response.status_code == 200
  121. scheduler.stop.assert_called_once()
  122. def test_live_stream_not_found(client):
  123. response = client.get("/api/live/panorama/group_1")
  124. assert response.status_code == 404
  125. def test_static_css_served(client):
  126. response = client.get("/static/style.css")
  127. assert response.status_code == 200
  128. assert "text/css" in response.headers["content-type"]
  129. def test_static_js_served(client):
  130. response = client.get("/static/app.js")
  131. assert response.status_code == 200
  132. assert "javascript" in response.headers["content-type"]
  133. def test_mutating_endpoints_require_api_key(client_with_mocks, monkeypatch):
  134. client, scanner, scheduler, _, _ = client_with_mocks
  135. monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
  136. # Scan
  137. response = client.post("/api/scan/group_1")
  138. assert response.status_code == 401
  139. # Poll start/stop
  140. response = client.post("/api/poll/group_1/start")
  141. assert response.status_code == 401
  142. response = client.post("/api/poll/group_1/stop")
  143. assert response.status_code == 401
  144. # Points add/delete
  145. response = client.post("/api/points/group_1", json={"pan": 30, "tilt": 0})
  146. assert response.status_code == 401
  147. response = client.delete("/api/points/group_1/1")
  148. assert response.status_code == 401
  149. scanner.run.assert_not_called()
  150. scheduler.start.assert_not_called()
  151. def test_mutating_endpoints_accept_api_key(client_with_mocks, monkeypatch):
  152. client, scanner, scheduler, _, _ = client_with_mocks
  153. monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
  154. response = client.post(
  155. "/api/poll/group_1/start",
  156. headers={"X-API-Key": "secret123"},
  157. )
  158. assert response.status_code == 200
  159. scheduler.start.assert_called_once()
  160. def test_readonly_endpoints_remain_open_with_api_key(client, monkeypatch):
  161. monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
  162. response = client.get("/api/status")
  163. assert response.status_code == 200
  164. response = client.get("/api/points/group_1")
  165. assert response.status_code == 200
  166. response = client.get("/api/scan/group_1/progress")
  167. assert response.status_code == 200
  168. def test_preview_does_not_update_saved_point_image(client_with_mocks):
  169. """点球机预览只临时抓拍并显示,不应更新保存点的 preview_image。"""
  170. client, _, _, _, _ = client_with_mocks
  171. # 创建一个带有固定 preview_image 的保存点
  172. response = client.post("/api/points/group_1", json={
  173. "pan": 30.0,
  174. "tilt": 0.0,
  175. "zoom": 1,
  176. "dwell_time": 3.0,
  177. "preview_image": "data/previews/group_1/existing.jpg",
  178. })
  179. assert response.status_code == 200
  180. point_id = response.json()["id"]
  181. response = client.post("/api/preview/group_1", json={
  182. "pan": 30.0,
  183. "tilt": 0.0,
  184. "zoom": 1,
  185. "point_id": point_id,
  186. })
  187. assert response.status_code == 200
  188. assert response.json()["snapshot_url"] is not None
  189. response = client.get(f"/api/points/group_1")
  190. assert response.status_code == 200
  191. point = next(p for p in response.json()["points"] if p["id"] == point_id)
  192. # 保存点的 preview_image 应保持原值,不被预览抓拍覆盖
  193. assert point["preview_image"] == "data/previews/group_1/existing.jpg"