test_vehicles_api.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. """
  2. Tests for the Vehicles API endpoints.
  3. """
  4. from contextlib import contextmanager
  5. from unittest.mock import Mock
  6. from fastapi import status
  7. from web.schemas import (
  8. VehicleBase,
  9. VersionOut,
  10. BoardOut,
  11. StandardArtifactOut,
  12. FeatureOut,
  13. CategoryBase,
  14. FeatureDefault,
  15. RemoteInfo,
  16. )
  17. class TestVehiclesAPI:
  18. """
  19. Tests for all Vehicles API endpoints.
  20. """
  21. @staticmethod
  22. @contextmanager
  23. def override_vehicles_service(client, mock_service):
  24. """Temporarily override the get_vehicles_service dependency."""
  25. from web.services.vehicles import get_vehicles_service
  26. client.app.dependency_overrides[get_vehicles_service] = lambda: mock_service
  27. try:
  28. yield
  29. finally:
  30. client.app.dependency_overrides.pop(get_vehicles_service, None)
  31. @staticmethod
  32. def dummy_version():
  33. return VersionOut(
  34. id="copter-4.5.0-stable",
  35. name="stable 4.5.0 (ardupilot)",
  36. type="stable",
  37. remote=RemoteInfo(
  38. name="ardupilot",
  39. url="https://github.com/ArduPilot/ardupilot.git"
  40. ),
  41. commit_ref="refs/tags/Copter-4.5.0",
  42. vehicle_id="copter",
  43. )
  44. @staticmethod
  45. def dummy_board(
  46. vehicle_id="copter",
  47. version_id="copter-4.5.0-stable",
  48. board_id="MatekH743",
  49. ):
  50. return BoardOut(
  51. id=board_id,
  52. name=board_id,
  53. vehicle_id=vehicle_id,
  54. version_id=version_id,
  55. )
  56. @staticmethod
  57. def dummy_standard_artifact():
  58. return StandardArtifactOut(
  59. name="arducopter.apj",
  60. url="https://firmware.ardupilot.org/Copter/stable-4.6.3/CubeOrange/arducopter.apj",
  61. format="apj",
  62. size=100,
  63. )
  64. @staticmethod
  65. def dummy_feature(
  66. vehicle_id="copter",
  67. version_id="copter-4.5.0-stable",
  68. board_id="MatekH743",
  69. feature_id="FEATURE_A",
  70. ):
  71. return FeatureOut(
  72. id=feature_id,
  73. name="Feature A",
  74. category=CategoryBase(id="cat1", name="Category 1"),
  75. description="A test feature",
  76. vehicle_id=vehicle_id,
  77. version_id=version_id,
  78. board_id=board_id,
  79. default=FeatureDefault(enabled=True, source="build-options-py"),
  80. dependencies=[],
  81. )
  82. # GET /vehicles
  83. def test_list_vehicles_returns_200_with_vehicle_list(self, client):
  84. """Returns 200 and a list of vehicles when service has data."""
  85. mock_vehicles_service = Mock()
  86. mock_vehicles_service.get_all_vehicles.return_value = [
  87. VehicleBase(id="copter", name="Copter"),
  88. VehicleBase(id="plane", name="Plane"),
  89. ]
  90. with self.override_vehicles_service(client, mock_vehicles_service):
  91. response = client.get("/api/v1/vehicles")
  92. assert response.status_code == status.HTTP_200_OK
  93. assert "application/json" in response.headers["content-type"]
  94. def test_list_vehicles_returns_200_with_empty_list(self, client):
  95. """Returns 200 with an empty list when no vehicles are available."""
  96. mock_vehicles_service = Mock()
  97. mock_vehicles_service.get_all_vehicles.return_value = []
  98. with self.override_vehicles_service(client, mock_vehicles_service):
  99. response = client.get("/api/v1/vehicles")
  100. assert response.status_code == status.HTTP_200_OK
  101. assert response.json() == []
  102. def test_list_vehicles_response_schema_has_required_fields(self, client):
  103. """Each vehicle in the response has 'id' and 'name' fields."""
  104. mock_vehicles_service = Mock()
  105. mock_vehicles_service.get_all_vehicles.return_value = [
  106. VehicleBase(id="copter", name="Copter"),
  107. ]
  108. with self.override_vehicles_service(client, mock_vehicles_service):
  109. response = client.get("/api/v1/vehicles")
  110. data = response.json()
  111. assert len(data) == 1
  112. assert "id" in data[0]
  113. assert "name" in data[0]
  114. def test_list_vehicles_method_not_allowed(self, client):
  115. """Non-GET methods on /vehicles return 405."""
  116. for method in [client.post, client.put, client.patch, client.delete]:
  117. response = method("/api/v1/vehicles")
  118. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  119. # GET /vehicles/{vehicle_id}
  120. def test_get_vehicle_returns_200_when_found(self, client):
  121. """Returns 200 when the vehicle exists."""
  122. mock_vehicles_service = Mock()
  123. mock_vehicles_service.get_vehicle.return_value = VehicleBase(id="copter", name="Copter")
  124. with self.override_vehicles_service(client, mock_vehicles_service):
  125. response = client.get("/api/v1/vehicles/copter")
  126. assert response.status_code == status.HTTP_200_OK
  127. def test_get_vehicle_returns_404_when_not_found(self, client):
  128. """Returns 404 when the service returns None."""
  129. mock_vehicles_service = Mock()
  130. mock_vehicles_service.get_vehicle.return_value = None
  131. with self.override_vehicles_service(client, mock_vehicles_service):
  132. response = client.get("/api/v1/vehicles/unknown")
  133. assert response.status_code == status.HTTP_404_NOT_FOUND
  134. def test_get_vehicle_404_detail_contains_vehicle_id(self, client):
  135. """The 404 error detail mentions the requested vehicle ID."""
  136. mock_vehicles_service = Mock()
  137. mock_vehicles_service.get_vehicle.return_value = None
  138. with self.override_vehicles_service(client, mock_vehicles_service):
  139. response = client.get("/api/v1/vehicles/some-vehicle-id")
  140. assert "some-vehicle-id" in response.json()["detail"]
  141. def test_get_vehicle_response_schema_has_required_fields(self, client):
  142. """Response body contains 'id' and 'name'."""
  143. mock_vehicles_service = Mock()
  144. mock_vehicles_service.get_vehicle.return_value = VehicleBase(id="copter", name="Copter")
  145. with self.override_vehicles_service(client, mock_vehicles_service):
  146. response = client.get("/api/v1/vehicles/copter")
  147. data = response.json()
  148. assert data["id"] == "copter"
  149. assert data["name"] == "Copter"
  150. def test_get_vehicle_service_called_with_correct_vehicle_id(self, client):
  151. """The vehicle_id path param is forwarded to the service."""
  152. mock_vehicles_service = Mock()
  153. mock_vehicles_service.get_vehicle.return_value = VehicleBase(id="plane", name="Plane")
  154. with self.override_vehicles_service(client, mock_vehicles_service):
  155. client.get("/api/v1/vehicles/plane")
  156. mock_vehicles_service.get_vehicle.assert_called_once_with("plane")
  157. def test_get_vehicle_method_not_allowed(self, client):
  158. """Non-GET methods on /vehicles/{vehicle_id} return 405."""
  159. for method in [client.post, client.put, client.patch, client.delete]:
  160. response = method("/api/v1/vehicles/copter")
  161. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  162. # GET /vehicles/{vehicle_id}/versions
  163. def test_list_versions_returns_200_with_version_list(self, client):
  164. """Returns 200 and a list of versions."""
  165. mock_vehicles_service = Mock()
  166. mock_vehicles_service.get_versions.return_value = [self.dummy_version()]
  167. with self.override_vehicles_service(client, mock_vehicles_service):
  168. response = client.get("/api/v1/vehicles/copter/versions")
  169. assert response.status_code == status.HTTP_200_OK
  170. assert "application/json" in response.headers["content-type"]
  171. def test_list_versions_returns_200_with_empty_list(self, client):
  172. """Returns 200 with an empty list when no versions exist."""
  173. mock_vehicles_service = Mock()
  174. mock_vehicles_service.get_versions.return_value = []
  175. with self.override_vehicles_service(client, mock_vehicles_service):
  176. response = client.get("/api/v1/vehicles/copter/versions")
  177. assert response.status_code == status.HTTP_200_OK
  178. assert response.json() == []
  179. def test_list_versions_response_schema_has_required_fields(self, client):
  180. """Each version in the response has the required schema fields."""
  181. mock_vehicles_service = Mock()
  182. mock_vehicles_service.get_versions.return_value = [self.dummy_version()]
  183. with self.override_vehicles_service(client, mock_vehicles_service):
  184. response = client.get("/api/v1/vehicles/copter/versions")
  185. data = response.json()
  186. assert len(data) == 1
  187. version = data[0]
  188. for field in ["id", "name", "type", "remote", "commit_ref", "vehicle_id"]:
  189. assert field in version
  190. assert "name" in version["remote"]
  191. assert "url" in version["remote"]
  192. def test_list_versions_type_query_param_forwarded_to_service(self, client):
  193. """The 'type' query param is passed as type_filter to the service."""
  194. mock_vehicles_service = Mock()
  195. mock_vehicles_service.get_versions.return_value = []
  196. with self.override_vehicles_service(client, mock_vehicles_service):
  197. client.get("/api/v1/vehicles/copter/versions?type=stable")
  198. mock_vehicles_service.get_versions.assert_called_once_with(
  199. "copter", type_filter="stable"
  200. )
  201. def test_list_versions_no_type_query_param_passes_none_to_service(self, client):
  202. """When 'type' is absent, type_filter=None is passed to the service."""
  203. mock_vehicles_service = Mock()
  204. mock_vehicles_service.get_versions.return_value = []
  205. with self.override_vehicles_service(client, mock_vehicles_service):
  206. client.get("/api/v1/vehicles/copter/versions")
  207. mock_vehicles_service.get_versions.assert_called_once_with(
  208. "copter", type_filter=None
  209. )
  210. def test_list_versions_vehicle_id_forwarded_to_service(self, client):
  211. """The vehicle_id path param is forwarded to the service."""
  212. mock_vehicles_service = Mock()
  213. mock_vehicles_service.get_versions.return_value = []
  214. with self.override_vehicles_service(client, mock_vehicles_service):
  215. client.get("/api/v1/vehicles/plane/versions")
  216. mock_vehicles_service.get_versions.assert_called_once_with(
  217. "plane", type_filter=None
  218. )
  219. def test_list_versions_method_not_allowed(self, client):
  220. """Non-GET methods on /vehicles/{vehicle_id}/versions return 405."""
  221. for method in [client.post, client.put, client.patch, client.delete]:
  222. response = method("/api/v1/vehicles/copter/versions")
  223. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  224. # GET /vehicles/{vehicle_id}/versions/{version_id}
  225. def test_get_version_returns_200_when_found(self, client):
  226. """Returns 200 when the version exists."""
  227. mock_vehicles_service = Mock()
  228. mock_vehicles_service.get_version.return_value = self.dummy_version()
  229. with self.override_vehicles_service(client, mock_vehicles_service):
  230. response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable")
  231. assert response.status_code == status.HTTP_200_OK
  232. def test_get_version_returns_404_when_not_found(self, client):
  233. """Returns 404 when the service returns None."""
  234. mock_vehicles_service = Mock()
  235. mock_vehicles_service.get_version.return_value = None
  236. with self.override_vehicles_service(client, mock_vehicles_service):
  237. response = client.get("/api/v1/vehicles/copter/versions/nonexistent")
  238. assert response.status_code == status.HTTP_404_NOT_FOUND
  239. def test_get_version_404_detail_contains_vehicle_and_version_id(self, client):
  240. """The 404 error detail mentions both the vehicle ID and version ID."""
  241. mock_vehicles_service = Mock()
  242. mock_vehicles_service.get_version.return_value = None
  243. with self.override_vehicles_service(client, mock_vehicles_service):
  244. response = client.get("/api/v1/vehicles/copter/versions/nonexistent")
  245. detail = response.json()["detail"]
  246. assert "copter" in detail
  247. assert "nonexistent" in detail
  248. def test_get_version_response_schema_has_required_fields(self, client):
  249. """Response body matches VersionOut schema."""
  250. mock_vehicles_service = Mock()
  251. mock_vehicles_service.get_version.return_value = self.dummy_version()
  252. with self.override_vehicles_service(client, mock_vehicles_service):
  253. response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable")
  254. data = response.json()
  255. for field in ["id", "name", "type", "remote", "commit_ref", "vehicle_id"]:
  256. assert field in data
  257. def test_get_version_service_called_with_correct_ids(self, client):
  258. """Both vehicle_id and version_id are forwarded to the service."""
  259. mock_vehicles_service = Mock()
  260. mock_vehicles_service.get_version.return_value = self.dummy_version()
  261. with self.override_vehicles_service(client, mock_vehicles_service):
  262. client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable")
  263. mock_vehicles_service.get_version.assert_called_once_with(
  264. "copter", "copter-4.5.0-stable"
  265. )
  266. def test_get_version_method_not_allowed(self, client):
  267. """Non-GET methods on /vehicles/{vehicle_id}/versions/{version_id} return 405."""
  268. for method in [client.post, client.put, client.patch, client.delete]:
  269. response = method("/api/v1/vehicles/copter/versions/v1")
  270. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  271. # GET /vehicles/{vehicle_id}/versions/{version_id}/boards
  272. def test_list_boards_returns_200_when_boards_exist(self, client):
  273. """Returns 200 and a list of boards when boards are available."""
  274. mock_vehicles_service = Mock()
  275. mock_vehicles_service.get_boards.return_value = [self.dummy_board()]
  276. with self.override_vehicles_service(client, mock_vehicles_service):
  277. response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards")
  278. assert response.status_code == status.HTTP_200_OK
  279. assert "application/json" in response.headers["content-type"]
  280. def test_list_boards_returns_404_when_no_boards(self, client):
  281. """Returns 404 (not 200) when service returns an empty list."""
  282. mock_vehicles_service = Mock()
  283. mock_vehicles_service.get_boards.return_value = []
  284. with self.override_vehicles_service(client, mock_vehicles_service):
  285. response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards")
  286. assert response.status_code == status.HTTP_404_NOT_FOUND
  287. def test_list_boards_404_detail_contains_vehicle_and_version_id(self, client):
  288. """The 404 error detail mentions both the vehicle ID and version ID."""
  289. mock_vehicles_service = Mock()
  290. mock_vehicles_service.get_boards.return_value = []
  291. with self.override_vehicles_service(client, mock_vehicles_service):
  292. response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards")
  293. detail = response.json()["detail"]
  294. assert "copter" in detail
  295. assert "copter-4.5.0-stable" in detail
  296. def test_list_boards_response_schema_has_required_fields(self, client):
  297. """Each board in the response has the required schema fields."""
  298. mock_vehicles_service = Mock()
  299. mock_vehicles_service.get_boards.return_value = [self.dummy_board()]
  300. with self.override_vehicles_service(client, mock_vehicles_service):
  301. response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards")
  302. data = response.json()
  303. assert len(data) == 1
  304. board = data[0]
  305. for field in ["id", "name", "vehicle_id", "version_id"]:
  306. assert field in board
  307. def test_list_boards_service_called_with_correct_ids(self, client):
  308. """Both vehicle_id and version_id are forwarded to the service."""
  309. mock_vehicles_service = Mock()
  310. mock_vehicles_service.get_boards.return_value = [self.dummy_board()]
  311. with self.override_vehicles_service(client, mock_vehicles_service):
  312. client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards")
  313. mock_vehicles_service.get_boards.assert_called_once_with(
  314. "copter", "copter-4.5.0-stable"
  315. )
  316. def test_list_boards_method_not_allowed(self, client):
  317. """Non-GET methods on .../boards return 405."""
  318. for method in [client.post, client.put, client.patch, client.delete]:
  319. response = method("/api/v1/vehicles/copter/versions/v1/boards")
  320. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  321. # GET /vehicles/{vehicle_id}/versions/{version_id}/boards/{board_id}
  322. def test_get_board_returns_200_when_found(self, client):
  323. """Returns 200 when the board exists."""
  324. mock_vehicles_service = Mock()
  325. mock_vehicles_service.get_board.return_value = self.dummy_board()
  326. with self.override_vehicles_service(client, mock_vehicles_service):
  327. response = client.get(
  328. "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743"
  329. )
  330. assert response.status_code == status.HTTP_200_OK
  331. def test_get_board_returns_404_when_not_found(self, client):
  332. """Returns 404 when the service returns None."""
  333. mock_vehicles_service = Mock()
  334. mock_vehicles_service.get_board.return_value = None
  335. with self.override_vehicles_service(client, mock_vehicles_service):
  336. response = client.get(
  337. "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/unknown"
  338. )
  339. assert response.status_code == status.HTTP_404_NOT_FOUND
  340. def test_get_board_404_detail_contains_board_id(self, client):
  341. """The 404 error detail mentions the requested board ID."""
  342. mock_vehicles_service = Mock()
  343. mock_vehicles_service.get_board.return_value = None
  344. with self.override_vehicles_service(client, mock_vehicles_service):
  345. response = client.get(
  346. "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/unknown"
  347. )
  348. assert "unknown" in response.json()["detail"]
  349. def test_get_board_response_schema_has_required_fields(self, client):
  350. """Response body matches BoardOut schema."""
  351. mock_vehicles_service = Mock()
  352. mock_vehicles_service.get_board.return_value = self.dummy_board()
  353. with self.override_vehicles_service(client, mock_vehicles_service):
  354. response = client.get(
  355. "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743"
  356. )
  357. data = response.json()
  358. for field in ["id", "name", "vehicle_id", "version_id"]:
  359. assert field in data
  360. def test_get_board_service_called_with_correct_ids(self, client):
  361. """All three path params are forwarded to the service."""
  362. mock_vehicles_service = Mock()
  363. mock_vehicles_service.get_board.return_value = self.dummy_board()
  364. with self.override_vehicles_service(client, mock_vehicles_service):
  365. client.get(
  366. "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743"
  367. )
  368. mock_vehicles_service.get_board.assert_called_once_with(
  369. "copter", "copter-4.5.0-stable", "MatekH743"
  370. )
  371. def test_get_board_method_not_allowed(self, client):
  372. """Non-GET methods on .../boards/{board_id} return 405."""
  373. for method in [client.post, client.put, client.patch, client.delete]:
  374. response = method("/api/v1/vehicles/copter/versions/v1/boards/b1")
  375. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  376. # GET /vehicles/{vehicle_id}/versions/{version_id}/boards/{board_id}/standard_artifacts
  377. _STANDARD_ARTIFACTS_URL = (
  378. "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/"
  379. "boards/MatekH743/standard_artifacts"
  380. )
  381. def test_list_board_standard_artifacts_returns_200(self, client):
  382. mock_vehicles_service = Mock()
  383. mock_vehicles_service.get_board_standard_artifacts.return_value = [
  384. self.dummy_standard_artifact()
  385. ]
  386. with self.override_vehicles_service(client, mock_vehicles_service):
  387. response = client.get(self._STANDARD_ARTIFACTS_URL)
  388. assert response.status_code == status.HTTP_200_OK
  389. def test_list_board_standard_artifacts_returns_404_when_not_found(self, client):
  390. mock_vehicles_service = Mock()
  391. mock_vehicles_service.get_board_standard_artifacts.return_value = None
  392. with self.override_vehicles_service(client, mock_vehicles_service):
  393. response = client.get(self._STANDARD_ARTIFACTS_URL)
  394. assert response.status_code == status.HTTP_404_NOT_FOUND
  395. def test_list_board_standard_artifacts_response_schema(self, client):
  396. mock_vehicles_service = Mock()
  397. mock_vehicles_service.get_board_standard_artifacts.return_value = [
  398. self.dummy_standard_artifact()
  399. ]
  400. with self.override_vehicles_service(client, mock_vehicles_service):
  401. response = client.get(self._STANDARD_ARTIFACTS_URL)
  402. data = response.json()
  403. assert data[0]["name"] == "arducopter.apj"
  404. assert data[0]["format"] == "apj"
  405. assert "url" in data[0]
  406. def test_list_board_standard_artifacts_service_called_with_correct_ids(self, client):
  407. mock_vehicles_service = Mock()
  408. mock_vehicles_service.get_board_standard_artifacts.return_value = [
  409. self.dummy_standard_artifact()
  410. ]
  411. with self.override_vehicles_service(client, mock_vehicles_service):
  412. client.get(self._STANDARD_ARTIFACTS_URL)
  413. mock_vehicles_service.get_board_standard_artifacts.assert_called_once_with(
  414. "copter", "copter-4.5.0-stable", "MatekH743"
  415. )
  416. # GET /vehicles/{vehicle_id}/versions/{version_id}/boards/{board_id}/features
  417. _FEATURES_URL = "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743/features"
  418. def test_list_features_returns_200_with_feature_list(self, client):
  419. """Returns 200 and a list of features."""
  420. mock_vehicles_service = Mock()
  421. mock_vehicles_service.get_features.return_value = [self.dummy_feature()]
  422. with self.override_vehicles_service(client, mock_vehicles_service):
  423. response = client.get(self._FEATURES_URL)
  424. assert response.status_code == status.HTTP_200_OK
  425. assert "application/json" in response.headers["content-type"]
  426. def test_list_features_returns_200_with_empty_list(self, client):
  427. """Returns 200 with empty list (unlike boards, empty features is not a 404)."""
  428. mock_vehicles_service = Mock()
  429. mock_vehicles_service.get_features.return_value = []
  430. with self.override_vehicles_service(client, mock_vehicles_service):
  431. response = client.get(self._FEATURES_URL)
  432. assert response.status_code == status.HTTP_200_OK
  433. assert response.json() == []
  434. def test_list_features_response_schema_has_required_fields(self, client):
  435. """Each feature in the response has the required schema fields."""
  436. mock_vehicles_service = Mock()
  437. mock_vehicles_service.get_features.return_value = [self.dummy_feature()]
  438. with self.override_vehicles_service(client, mock_vehicles_service):
  439. response = client.get(self._FEATURES_URL)
  440. data = response.json()
  441. assert len(data) == 1
  442. feature = data[0]
  443. for field in ["id", "name", "category", "vehicle_id", "version_id", "board_id", "default", "dependencies"]:
  444. assert field in feature
  445. assert "enabled" in feature["default"]
  446. assert "source" in feature["default"]
  447. def test_list_features_category_id_query_param_forwarded_to_service(self, client):
  448. """The 'category_id' query param is forwarded to the service."""
  449. mock_vehicles_service = Mock()
  450. mock_vehicles_service.get_features.return_value = []
  451. with self.override_vehicles_service(client, mock_vehicles_service):
  452. client.get(self._FEATURES_URL + "?category_id=cat1")
  453. mock_vehicles_service.get_features.assert_called_once_with(
  454. "copter", "copter-4.5.0-stable", "MatekH743", "cat1"
  455. )
  456. def test_list_features_no_category_id_passes_none_to_service(self, client):
  457. """When 'category_id' is absent, None is passed to the service."""
  458. mock_vehicles_service = Mock()
  459. mock_vehicles_service.get_features.return_value = []
  460. with self.override_vehicles_service(client, mock_vehicles_service):
  461. client.get(self._FEATURES_URL)
  462. mock_vehicles_service.get_features.assert_called_once_with(
  463. "copter", "copter-4.5.0-stable", "MatekH743", None
  464. )
  465. def test_list_features_service_called_with_correct_path_params(self, client):
  466. """All three path params are forwarded to the service."""
  467. mock_vehicles_service = Mock()
  468. mock_vehicles_service.get_features.return_value = []
  469. with self.override_vehicles_service(client, mock_vehicles_service):
  470. client.get(
  471. "/api/v1/vehicles/plane/versions/plane-4.4.0-stable/boards/CubeOrange/features"
  472. )
  473. mock_vehicles_service.get_features.assert_called_once_with(
  474. "plane", "plane-4.4.0-stable", "CubeOrange", None
  475. )
  476. def test_list_features_method_not_allowed(self, client):
  477. """Non-GET methods on .../features return 405."""
  478. for method in [client.post, client.put, client.patch, client.delete]:
  479. response = method(self._FEATURES_URL)
  480. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  481. # GET /vehicles/{vehicle_id}/versions/{version_id}/boards/{board_id}/features/{feature_id}
  482. def test_get_feature_returns_200_when_found(self, client):
  483. """Returns 200 when the feature exists."""
  484. mock_vehicles_service = Mock()
  485. mock_vehicles_service.get_feature.return_value = self.dummy_feature()
  486. with self.override_vehicles_service(client, mock_vehicles_service):
  487. response = client.get(f"{self._FEATURES_URL}/FEATURE_A")
  488. assert response.status_code == status.HTTP_200_OK
  489. def test_get_feature_returns_404_when_not_found(self, client):
  490. """Returns 404 when the service returns None."""
  491. mock_vehicles_service = Mock()
  492. mock_vehicles_service.get_feature.return_value = None
  493. with self.override_vehicles_service(client, mock_vehicles_service):
  494. response = client.get(f"{self._FEATURES_URL}/UNKNOWN_FEATURE")
  495. assert response.status_code == status.HTTP_404_NOT_FOUND
  496. def test_get_feature_404_detail_contains_feature_id(self, client):
  497. """The 404 error detail mentions the requested feature ID."""
  498. mock_vehicles_service = Mock()
  499. mock_vehicles_service.get_feature.return_value = None
  500. with self.override_vehicles_service(client, mock_vehicles_service):
  501. response = client.get(f"{self._FEATURES_URL}/UNKNOWN_FEATURE")
  502. assert "UNKNOWN_FEATURE" in response.json()["detail"]
  503. def test_get_feature_response_schema_has_required_fields(self, client):
  504. """Response body matches FeatureOut schema."""
  505. mock_vehicles_service = Mock()
  506. mock_vehicles_service.get_feature.return_value = self.dummy_feature()
  507. with self.override_vehicles_service(client, mock_vehicles_service):
  508. response = client.get(f"{self._FEATURES_URL}/FEATURE_A")
  509. data = response.json()
  510. for field in ["id", "name", "category", "vehicle_id", "version_id", "board_id", "default", "dependencies"]:
  511. assert field in data
  512. def test_get_feature_service_called_with_correct_ids(self, client):
  513. """All four path params are forwarded to the service."""
  514. mock_vehicles_service = Mock()
  515. mock_vehicles_service.get_feature.return_value = self.dummy_feature()
  516. with self.override_vehicles_service(client, mock_vehicles_service):
  517. client.get(f"{self._FEATURES_URL}/FEATURE_A")
  518. mock_vehicles_service.get_feature.assert_called_once_with(
  519. "copter", "copter-4.5.0-stable", "MatekH743", "FEATURE_A"
  520. )
  521. def test_get_feature_method_not_allowed(self, client):
  522. """Non-GET methods on .../features/{feature_id} return 405."""
  523. for method in [client.post, client.put, client.patch, client.delete]:
  524. response = method(f"{self._FEATURES_URL}/FEATURE_A")
  525. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED