diff --git a/Makefile b/Makefile index f8f94f6..fa15f27 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,8 @@ flake8: flake8 --ignore E501,E402,F401,W503,C0414 ./tests || true pylint: - pylint --extension-pkg-whitelist='pydantic' ./lib || true - pylint --extension-pkg-whitelist='pydantic' --disable=E0401,W0621 ./tests || true + pylint ./lib || true + pylint --disable=E0401,W0621 ./tests || true ruff: ruff check --fix ./lib || true diff --git a/lib/controllers/motor.py b/lib/controllers/motor.py index fc5a41a..4d58fb2 100644 --- a/lib/controllers/motor.py +++ b/lib/controllers/motor.py @@ -26,18 +26,6 @@ class MotorController: - Create a rocketpy.Motor object from a Motor model object. """ - def __init__(self, motor: Motor): - self.guard(motor) - self._motor = motor - - @property - def motor(self) -> Motor: - return self._motor - - @motor.setter - def motor(self, motor: Motor): - self._motor = motor - @staticmethod def guard(motor: Motor): if ( @@ -51,7 +39,10 @@ def guard(motor: Motor): # TODO: extend guard to check motor kinds and tank kinds specifics - async def create_motor(self) -> Union[MotorCreated, HTTPException]: + @classmethod + async def create_motor( + cls, motor: Motor + ) -> Union[MotorCreated, HTTPException]: """ Create a models.Motor in the database. @@ -59,7 +50,8 @@ async def create_motor(self) -> Union[MotorCreated, HTTPException]: views.MotorCreated """ try: - async with MotorRepository(self.motor) as motor_repo: + cls.guard(motor) + async with MotorRepository(motor) as motor_repo: await motor_repo.create_motor() except PyMongoError as e: logger.error(f"controllers.motor.create_motor: PyMongoError {e}") @@ -173,8 +165,9 @@ async def get_rocketpy_motor_binary( f"Call to controllers.motor.get_rocketpy_motor_binary completed for Motor {motor_id}" ) + @classmethod async def update_motor_by_id( - self, motor_id: str + cls, motor_id: str, motor: Motor ) -> Union[MotorUpdated, HTTPException]: """ Update a motor in the database. @@ -189,7 +182,8 @@ async def update_motor_by_id( HTTP 404 Not Found: If the motor is not found in the database. """ try: - async with MotorRepository(self.motor) as motor_repo: + cls.guard(motor) + async with MotorRepository(motor) as motor_repo: await motor_repo.update_motor_by_id(motor_id) except PyMongoError as e: logger.error(f"controllers.motor.update_motor: PyMongoError {e}") diff --git a/lib/routes/motor.py b/lib/routes/motor.py index d63f39e..5a89da7 100644 --- a/lib/routes/motor.py +++ b/lib/routes/motor.py @@ -38,7 +38,7 @@ async def create_motor(motor: Motor, motor_kind: MotorKinds) -> MotorCreated: """ with tracer.start_as_current_span("create_motor"): motor.set_motor_kind(motor_kind) - return await MotorController(motor).create_motor() + return await MotorController.create_motor(motor) @router.get("/{motor_id}") @@ -68,11 +68,11 @@ async def update_motor( """ with tracer.start_as_current_span("update_motor"): motor.set_motor_kind(motor_kind) - return await MotorController(motor).update_motor_by_id(motor_id) + return await MotorController.update_motor_by_id(motor_id, motor) @router.get( - "/rocketpy/{motor_id}", + "/{motor_id}/rocketpy", responses={ 203: { "description": "Binary file download", diff --git a/lib/views/environment.py b/lib/views/environment.py index a7478f5..d3eecee 100644 --- a/lib/views/environment.py +++ b/lib/views/environment.py @@ -1,6 +1,6 @@ from typing import Optional, Any from datetime import datetime, timedelta -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from lib.models.environment import AtmosphericModelTypes from lib.utils import to_python_primitive @@ -49,8 +49,9 @@ class EnvSummary(BaseModel): geodesic_to_utm: Optional[Any] = None utm_to_geodesic: Optional[Any] = None - class Config: - json_encoders = {Any: to_python_primitive} + model_config = ConfigDict( + json_encoders={Any: to_python_primitive} + ) class EnvCreated(BaseModel): diff --git a/lib/views/flight.py b/lib/views/flight.py index 00ad4f4..5bf5e4a 100644 --- a/lib/views/flight.py +++ b/lib/views/flight.py @@ -1,5 +1,5 @@ from typing import Optional, Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from lib.models.flight import Flight from lib.views.rocket import RocketView, RocketSummary from lib.views.environment import EnvSummary @@ -151,8 +151,9 @@ class FlightSummary(RocketSummary, EnvSummary): z_impact: Optional[Any] = None flight_phases: Optional[Any] = None - class Config: - json_encoders = {Any: to_python_primitive} + model_config = ConfigDict( + json_encoders={Any: to_python_primitive} + ) class FlightCreated(BaseModel): diff --git a/lib/views/motor.py b/lib/views/motor.py index 6424096..d14fc03 100644 --- a/lib/views/motor.py +++ b/lib/views/motor.py @@ -1,5 +1,5 @@ from typing import List, Any, Optional -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from lib.models.motor import Motor, MotorKinds, CoordinateSystemOrientation from lib.utils import to_python_primitive @@ -69,8 +69,9 @@ class MotorSummary(BaseModel): total_mass_flow_rate: Optional[Any] = None thrust: Optional[Any] = None - class Config: - json_encoders = {Any: to_python_primitive} + model_config = ConfigDict( + json_encoders={Any: to_python_primitive} + ) class MotorCreated(BaseModel): diff --git a/lib/views/rocket.py b/lib/views/rocket.py index 691ae97..3dabbf5 100644 --- a/lib/views/rocket.py +++ b/lib/views/rocket.py @@ -1,5 +1,5 @@ from typing import Any, Optional -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from lib.models.rocket import Rocket, CoordinateSystemOrientation from lib.views.motor import MotorView, MotorSummary from lib.utils import to_python_primitive @@ -38,8 +38,9 @@ class RocketSummary(MotorSummary): thrust_to_weight: Optional[Any] = None total_lift_coeff_der: Optional[Any] = None - class Config: - json_encoders = {Any: to_python_primitive} + model_config = ConfigDict( + json_encoders={Any: to_python_primitive} + ) class RocketCreated(BaseModel): diff --git a/tests/test_routes/test_motor_route.py b/tests/test_routes/test_motor_route.py new file mode 100644 index 0000000..3f8cc8f --- /dev/null +++ b/tests/test_routes/test_motor_route.py @@ -0,0 +1,326 @@ +from unittest.mock import patch +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException +from lib.models.motor import Motor +from lib.controllers.motor import MotorController +from lib.views.motor import ( + MotorKinds, + MotorCreated, + MotorUpdated, + MotorDeleted, + MotorSummary, + MotorView, +) +from lib import app + +client = TestClient(app) + + +@pytest.fixture +def stub_motor(): + motor = Motor( + thrust_source=[[0, 0]], + burn_time=0, + nozzle_radius=0, + dry_mass=0, + dry_inertia=[0, 0, 0], + center_of_dry_mass_position=0, + ) + motor_json = motor.model_dump_json() + return json.loads(motor_json) + + +@pytest.fixture +def stub_motor_summary(): + motor_summary = MotorSummary() + motor_summary_json = motor_summary.model_dump_json() + return json.loads(motor_summary_json) + + +def test_create_motor(stub_motor): + with patch.object( + MotorController, + "create_motor", + return_value=MotorCreated(motor_id="123"), + ) as mock_create_motor: + with patch.object( + Motor, "set_motor_kind", side_effect=None + ) as mock_set_motor_kind: + response = client.post( + "/motors/", json=stub_motor, params={"motor_kind": "HYBRID"} + ) + assert response.status_code == 200 + assert response.json() == { + "motor_id": "123", + "message": "Motor successfully created", + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) + mock_create_motor.assert_called_once_with(Motor(**stub_motor)) + + +def test_create_motor_optional_params(): + test_object = { + "thrust_source": [[0, 0]], + "burn_time": 1, + "nozzle_radius": 1, + "dry_mass": 1, + "center_of_dry_mass_position": 1, + "dry_inertia": [0, 0, 0], + "interpolation_method": "LINEAR", + "coordinate_system_orientation": "NOZZLE_TO_COMBUSTION_CHAMBER", + "reshape_thrust_curve": False, + } + with patch.object( + MotorController, + "create_motor", + return_value=MotorCreated(motor_id="123"), + ) as mock_create_motor: + with patch.object( + Motor, "set_motor_kind", side_effect=None + ) as mock_set_motor_kind: + response = client.post( + "/motors/", json=test_object, params={"motor_kind": "HYBRID"} + ) + assert response.status_code == 200 + assert response.json() == { + "motor_id": "123", + "message": "Motor successfully created", + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) + mock_create_motor.assert_called_once_with(Motor(**test_object)) + + +def test_create_motor_invalid_input(): + response = client.post( + "/motors/", json={"burn_time": "foo", "nozzle_radius": "bar"} + ) + assert response.status_code == 422 + + +def test_create_motor_server_error(stub_motor): + with patch.object( + MotorController, "create_motor", side_effect=Exception("error") + ): + with pytest.raises(Exception): + response = client.post("/motors/", json=stub_motor) + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to create motor: error" + } + + +def test_read_motor(stub_motor): + stub_motor.update({"selected_motor_kind": "HYBRID"}) + with patch.object( + MotorController, + "get_motor_by_id", + return_value=MotorView(**stub_motor), + ) as mock_read_motor: + response = client.get("/motors/123") + assert response.status_code == 200 + assert response.json() == stub_motor + mock_read_motor.assert_called_once_with("123") + + +def test_read_motor_not_found(): + with patch.object( + MotorController, + "get_motor_by_id", + side_effect=HTTPException(status_code=404), + ) as mock_read_motor: + response = client.get("/motors/123") + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_read_motor.assert_called_once_with("123") + + +def test_read_motor_server_error(): + with patch.object( + MotorController, "get_motor_by_id", side_effect=Exception("error") + ): + with pytest.raises(Exception): + response = client.get("/motors/123") + assert response.status_code == 500 + assert response.json() == {"detail": "Failed to read motor: error"} + + +def test_update_motor(stub_motor): + with patch.object( + MotorController, + "update_motor_by_id", + return_value=MotorUpdated(motor_id="123"), + ) as mock_update_motor: + with patch.object( + Motor, "set_motor_kind", side_effect=None + ) as mock_set_motor_kind: + response = client.put( + "/motors/123", json=stub_motor, params={"motor_kind": "HYBRID"} + ) + assert response.status_code == 200 + assert response.json() == { + "motor_id": "123", + "message": "Motor successfully updated", + } + mock_update_motor.assert_called_once_with( + "123", Motor(**stub_motor) + ) + mock_set_motor_kind.assert_called_once_with(MotorKinds.HYBRID) + + +def test_update_motor_invalid_input(): + response = client.put( + "/motors/123", + json={"burn_time": "foo", "nozzle_radius": "bar"}, + params={"motor_kind": "HYBRID"}, + ) + assert response.status_code == 422 + + +def test_update_motor_not_found(stub_motor): + with patch.object( + MotorController, + "update_motor_by_id", + side_effect=HTTPException(status_code=404), + ): + response = client.put( + "/motors/123", json=stub_motor, params={"motor_kind": "HYBRID"} + ) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_update_motor_server_error(stub_motor): + with patch.object( + MotorController, + "update_motor_by_id", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.put( + "/motors/123", json=stub_motor, params={"motor_kind": "HYBRID"} + ) + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to update motor: error" + } + + +def test_delete_motor(): + with patch.object( + MotorController, + "delete_motor_by_id", + return_value=MotorDeleted(motor_id="123"), + ) as mock_delete_motor: + response = client.delete("/motors/123") + assert response.status_code == 200 + assert response.json() == { + "motor_id": "123", + "message": "Motor successfully deleted", + } + mock_delete_motor.assert_called_once_with("123") + + +def test_delete_motor_not_found(): + with patch.object( + MotorController, + "delete_motor_by_id", + return_value=MotorDeleted(motor_id="123"), + ) as mock_delete_motor: + response = client.delete("/motors/123") + assert response.status_code == 200 + assert response.json() == { + "motor_id": "123", + "message": "Motor successfully deleted", + } + mock_delete_motor.assert_called_once_with("123") + + +def test_delete_motor_server_error(): + with patch.object( + MotorController, + "delete_motor_by_id", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.delete("/motors/123") + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to delete motor: error" + } + + +def test_simulate_motor(stub_motor_summary): + with patch.object( + MotorController, + "simulate_motor", + return_value=MotorSummary(**stub_motor_summary), + ) as mock_simulate_motor: + response = client.get("/motors/123/summary") + assert response.status_code == 200 + assert response.json() == stub_motor_summary + mock_simulate_motor.assert_called_once_with("123") + + +def test_simulate_motor_not_found(): + with patch.object( + MotorController, + "simulate_motor", + side_effect=HTTPException(status_code=404), + ) as mock_simulate_motor: + response = client.get("/motors/123/summary") + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_simulate_motor.assert_called_once_with("123") + + +def test_simulate_motor_server_error(): + with patch.object( + MotorController, + "simulate_motor", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.get("/motors/123/summary") + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to simulate motor: error" + } + + +def test_read_rocketpy_motor(): + with patch.object( + MotorController, "get_rocketpy_motor_binary", return_value=b'rocketpy' + ) as mock_read_rocketpy_motor: + response = client.get("/motors/123/rocketpy") + assert response.status_code == 203 + assert response.content == b'rocketpy' + assert response.headers["content-type"] == "application/octet-stream" + mock_read_rocketpy_motor.assert_called_once_with("123") + + +def test_read_rocketpy_motor_not_found(): + with patch.object( + MotorController, + "get_rocketpy_motor_binary", + side_effect=HTTPException(status_code=404), + ) as mock_read_rocketpy_motor: + response = client.get("/motors/123/rocketpy") + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_read_rocketpy_motor.assert_called_once_with("123") + + +def test_read_rocketpy_motor_server_error(): + with patch.object( + MotorController, + "get_rocketpy_motor_binary", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.get("/motors/123/rocketpy") + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to read rocketpy motor: error" + }