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/environment.py b/lib/controllers/environment.py index 20f4874..40bd7bd 100644 --- a/lib/controllers/environment.py +++ b/lib/controllers/environment.py @@ -19,9 +19,6 @@ class EnvController: """ Controller for the Environment model. - Init Attributes: - env: models.Env - Enables: - Simulation of a RocketPy Environment from models.Env - CRUD operations over models.Env on the database @@ -35,6 +32,7 @@ async def create_env(env: Env) -> Union[EnvCreated, HTTPException]: Returns: views.EnvCreated """ + env_repo = None try: async with EnvRepository(env) as env_repo: await env_repo.create_env() @@ -58,9 +56,15 @@ async def create_env(env: Env) -> Union[EnvCreated, HTTPException]: else: return EnvCreated(env_id=env_repo.env_id) finally: - logger.info( - f"Call to controllers.environment.create_env completed for Env {env_repo.env_id}" + env_id = ( + getattr(env_repo, 'env_id', 'unknown') + if env_repo + else 'unknown' ) + if env_repo: + logger.info( + f"Call to controllers.environment.create_env completed for Env {env_id}" + ) @staticmethod async def get_env_by_id(env_id: str) -> Union[Env, HTTPException]: diff --git a/lib/controllers/flight.py b/lib/controllers/flight.py index 34c672d..1d2aa8d 100644 --- a/lib/controllers/flight.py +++ b/lib/controllers/flight.py @@ -25,9 +25,6 @@ class FlightController: """ Controller for the Flight model. - Init Attributes: - flight (models.Flight): Flight model object. - Enables: - Create a RocketPyFlight object from a Flight model object. - Generate trajectory simulation from a RocketPyFlight object. @@ -39,34 +36,24 @@ class FlightController: """ - def __init__( - self, - flight: Flight, - ): - self.guard(flight) - self._flight = flight - - @property - def flight(self) -> Flight: - return self._flight - - @flight.setter - def flight(self, flight: Flight): - self._flight = flight - @staticmethod def guard(flight: Flight): RocketController.guard(flight.rocket) - async def create_flight(self) -> Union[FlightCreated, HTTPException]: + @classmethod + async def create_flight( + cls, flight: Flight + ) -> Union[FlightCreated, HTTPException]: """ Create a flight in the database. Returns: views.FlightCreated """ + flight_repo = None try: - async with FlightRepository(self.flight) as flight_repo: + cls.guard(flight) + async with FlightRepository(flight) as flight_repo: await flight_repo.create_flight() except PyMongoError as e: logger.error(f"controllers.flight.create_flight: PyMongoError {e}") @@ -86,8 +73,13 @@ async def create_flight(self) -> Union[FlightCreated, HTTPException]: else: return FlightCreated(flight_id=flight_repo.flight_id) finally: + flight_id = ( + getattr(flight_repo, 'flight_id', 'unknown') + if flight_repo + else 'unknown' + ) logger.info( - f"Call to controllers.flight.create_flight completed for Flight {flight_repo.flight_id}" + f"Call to controllers.flight.create_flight completed for Flight {flight_id}" ) @staticmethod @@ -189,8 +181,9 @@ async def get_rocketpy_flight_binary( f"Call to controllers.flight.get_rocketpy_flight_binary completed for Flight {flight_id}" ) + @classmethod async def update_flight_by_id( - self, flight_id: str + cls, flight: Flight, flight_id: str ) -> Union[FlightUpdated, HTTPException]: """ Update a models.Flight in the database. @@ -205,7 +198,8 @@ async def update_flight_by_id( HTTP 404 Not Found: If the flight is not found in the database. """ try: - async with FlightRepository(self.flight) as flight_repo: + cls.guard(flight) + async with FlightRepository(flight) as flight_repo: await flight_repo.update_flight_by_id(flight_id) except PyMongoError as e: logger.error(f"controllers.flight.update_flight: PyMongoError {e}") diff --git a/lib/controllers/motor.py b/lib/controllers/motor.py index fc5a41a..824dfd1 100644 --- a/lib/controllers/motor.py +++ b/lib/controllers/motor.py @@ -19,25 +19,10 @@ class MotorController: """ Controller for the motor model. - Init Attributes: - motor (models.Motor): Motor model object. - Enables: - 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,15 +36,20 @@ 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. Returns: views.MotorCreated """ + motor_repo = None 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}") @@ -79,9 +69,15 @@ async def create_motor(self) -> Union[MotorCreated, HTTPException]: else: return MotorCreated(motor_id=motor_repo.motor_id) finally: - logger.info( - f"Call to controllers.motor.create_motor completed for Motor {motor_repo.motor_id}" + motor_id = ( + getattr(motor_repo, 'motor_id', 'unknown') + if motor_repo + else 'unknown' ) + if motor_repo: + logger.info( + f"Call to controllers.motor.create_motor completed for Motor {motor_id}" + ) @staticmethod async def get_motor_by_id( @@ -173,8 +169,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 +186,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/controllers/rocket.py b/lib/controllers/rocket.py index ef5524c..5b42b60 100644 --- a/lib/controllers/rocket.py +++ b/lib/controllers/rocket.py @@ -22,41 +22,28 @@ class RocketController: """ Controller for the Rocket model. - Init Attributes: - rocket: models.Rocket. - Enables: - CRUD operations over models.Rocket on the database. """ - def __init__( - self, - rocket: Rocket, - ): - self.guard(rocket) - self._rocket = rocket - - @property - def rocket(self) -> Rocket: - return self._rocket - - @rocket.setter - def rocket(self, rocket: Rocket): - self._rocket = rocket - @staticmethod def guard(rocket: Rocket): MotorController.guard(rocket.motor) - async def create_rocket(self) -> Union[RocketCreated, HTTPException]: + @classmethod + async def create_rocket( + cls, rocket: Rocket + ) -> Union[RocketCreated, HTTPException]: """ Create a models.Rocket in the database. Returns: views.RocketCreated """ + rocket_repo = None try: - async with RocketRepository(self.rocket) as rocket_repo: + cls.guard(rocket) + async with RocketRepository(rocket) as rocket_repo: await rocket_repo.create_rocket() except PyMongoError as e: logger.error(f"controllers.rocket.create_rocket: PyMongoError {e}") @@ -76,9 +63,15 @@ async def create_rocket(self) -> Union[RocketCreated, HTTPException]: else: return RocketCreated(rocket_id=rocket_repo.rocket_id) finally: - logger.info( - f"Call to controllers.rocket.create_rocket completed for Rocket {rocket_repo.rocket_id}" + rocket_id = ( + getattr(rocket_repo, 'rocket_id', 'unknown') + if rocket_repo + else 'unknown' ) + if rocket_repo: + logger.info( + f"Call to controllers.rocket.create_rocket completed for Rocket {rocket_id}" + ) @staticmethod async def get_rocket_by_id( @@ -175,8 +168,9 @@ async def get_rocketpy_rocket_binary( f"Call to controllers.rocket.get_rocketpy_rocket_binary completed for Rocket {rocket_id}" ) + @classmethod async def update_rocket_by_id( - self, rocket_id: str + cls, rocket: Rocket, rocket_id: str ) -> Union[RocketUpdated, HTTPException]: """ Update a models.Rocket in the database. @@ -191,7 +185,8 @@ async def update_rocket_by_id( HTTP 404 Not Found: If the rocket is not found in the database. """ try: - async with RocketRepository(self.rocket) as rocket_repo: + cls.guard(rocket) + async with RocketRepository(rocket) as rocket_repo: await rocket_repo.update_rocket_by_id(rocket_id) except PyMongoError as e: logger.error(f"controllers.rocket.update_rocket: PyMongoError {e}") diff --git a/lib/models/motor.py b/lib/models/motor.py index 302dfc9..c8121bf 100644 --- a/lib/models/motor.py +++ b/lib/models/motor.py @@ -46,25 +46,25 @@ class MotorTank(BaseModel): discretize: int # Level based tank parameters - liquid_height: Optional[float] + liquid_height: Optional[float] = None # Mass based tank parameters - liquid_mass: Optional[float] - gas_mass: Optional[float] + liquid_mass: Optional[float] = None + gas_mass: Optional[float] = None # Mass flow based tank parameters - gas_mass_flow_rate_in: Optional[float] - gas_mass_flow_rate_out: Optional[float] - liquid_mass_flow_rate_in: Optional[float] - liquid_mass_flow_rate_out: Optional[float] - initial_liquid_mass: Optional[float] - initial_gas_mass: Optional[float] + gas_mass_flow_rate_in: Optional[float] = None + gas_mass_flow_rate_out: Optional[float] = None + liquid_mass_flow_rate_in: Optional[float] = None + liquid_mass_flow_rate_out: Optional[float] = None + initial_liquid_mass: Optional[float] = None + initial_gas_mass: Optional[float] = None # Ullage based tank parameters - ullage: Optional[float] + ullage: Optional[float] = None # Optional parameters - name: Optional[str] + name: Optional[str] = None # Computed parameters tank_kind: TankKinds = TankKinds.MASS_FLOW diff --git a/lib/routes/flight.py b/lib/routes/flight.py index 7045e2c..cb921db 100644 --- a/lib/routes/flight.py +++ b/lib/routes/flight.py @@ -43,7 +43,7 @@ async def create_flight( """ with tracer.start_as_current_span("create_flight"): flight.rocket.motor.set_motor_kind(motor_kind) - return await FlightController(flight).create_flight() + return await FlightController.create_flight(flight) @router.get("/{flight_id}") @@ -146,7 +146,7 @@ async def update_flight( """ with tracer.start_as_current_span("update_flight"): flight.rocket.motor.set_motor_kind(motor_kind) - return await FlightController(flight).update_flight_by_id(flight_id) + return await FlightController.update_flight_by_id(flight, flight_id) @router.get("/{flight_id}/summary") 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/routes/rocket.py b/lib/routes/rocket.py index e0b7a8a..cbbe93d 100644 --- a/lib/routes/rocket.py +++ b/lib/routes/rocket.py @@ -41,7 +41,7 @@ async def create_rocket( """ with tracer.start_as_current_span("create_rocket"): rocket.motor.set_motor_kind(motor_kind) - return await RocketController(rocket).create_rocket() + return await RocketController.create_rocket(rocket) @router.get("/{rocket_id}") @@ -73,7 +73,7 @@ async def update_rocket( """ with tracer.start_as_current_span("update_rocket"): rocket.motor.set_motor_kind(motor_kind) - return await RocketController(rocket).update_rocket_by_id(rocket_id) + return await RocketController.update_rocket_by_id(rocket, rocket_id) @router.get( diff --git a/lib/views/environment.py b/lib/views/environment.py index a7478f5..e3fcd31 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,7 @@ 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..f06cae4 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,7 @@ 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..858bc0e 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,7 @@ 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..45664ad 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,7 @@ 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_environment_route.py b/tests/test_routes/test_environment_route.py deleted file mode 100644 index e0e3bb6..0000000 --- a/tests/test_routes/test_environment_route.py +++ /dev/null @@ -1,291 +0,0 @@ -from unittest.mock import patch -import json -import pytest -from fastapi.testclient import TestClient -from fastapi import HTTPException -from lib.models.environment import Env -from lib.controllers.environment import EnvController -from lib.views.environment import ( - EnvCreated, - EnvUpdated, - EnvDeleted, - EnvSummary, -) -from lib import app - -client = TestClient(app) - - -@pytest.fixture -def stub_env(): - env = Env(latitude=0, longitude=0) - env_json = env.model_dump_json() - return json.loads(env_json) - - -@pytest.fixture -def stub_env_summary(): - env_summary = EnvSummary() - env_summary_json = env_summary.model_dump_json() - return json.loads(env_summary_json) - - -def test_create_env(stub_env): - with patch.object( - EnvController, "create_env", return_value=EnvCreated(env_id="123") - ) as mock_create_env: - response = client.post("/environments/", json=stub_env) - assert response.status_code == 200 - assert response.json() == { - "env_id": "123", - "message": "Environment successfully created", - } - mock_create_env.assert_called_once_with(Env(**stub_env)) - - -def test_create_env_optional_params(): - test_object = { - "latitude": 0, - "longitude": 0, - "elevation": 1, - "atmospheric_model_type": "STANDARD_ATMOSPHERE", - "atmospheric_model_file": None, - "date": "2021-01-01T00:00:00", - } - with patch.object( - EnvController, "create_env", return_value=EnvCreated(env_id="123") - ) as mock_create_env: - response = client.post("/environments/", json=test_object) - assert response.status_code == 200 - assert response.json() == { - "env_id": "123", - "message": "Environment successfully created", - } - mock_create_env.assert_called_once_with(Env(**test_object)) - - -def test_create_env_invalid_input(): - response = client.post( - "/environments/", json={"latitude": "foo", "longitude": "bar"} - ) - assert response.status_code == 422 - - -def test_create_env_server_error(stub_env): - with patch.object( - EnvController, "create_env", side_effect=Exception("error") - ): - with pytest.raises(Exception): - response = client.post("/environments/", json=stub_env) - assert response.status_code == 500 - assert response.json() == { - "detail": "Failed to create environment: error" - } - - -def test_read_env(stub_env): - with patch.object( - EnvController, "get_env_by_id", return_value=Env(**stub_env) - ) as mock_read_env: - response = client.get("/environments/123") - assert response.status_code == 200 - assert response.json() == stub_env - mock_read_env.assert_called_once_with("123") - - -def test_read_env_not_found(): - with patch.object( - EnvController, - "get_env_by_id", - side_effect=HTTPException( - status_code=404, detail="Environment not found" - ), - ) as mock_read_env: - response = client.get("/environments/123") - assert response.status_code == 404 - assert response.json() == {"detail": "Environment not found"} - mock_read_env.assert_called_once_with("123") - - -def test_read_env_server_error(): - with patch.object( - EnvController, "get_env_by_id", side_effect=Exception("error") - ): - with pytest.raises(Exception): - response = client.get("/environments/123") - assert response.status_code == 500 - assert response.json() == { - "detail": "Failed to read environment: error" - } - - -def test_update_env(stub_env): - with patch.object( - EnvController, - "update_env_by_id", - return_value=EnvUpdated(env_id="123"), - ) as mock_update_env: - response = client.put("/environments/123", json=stub_env) - assert response.status_code == 200 - assert response.json() == { - "env_id": "123", - "message": "Environment successfully updated", - } - mock_update_env.assert_called_once_with("123", Env(**stub_env)) - - -def test_update_env_invalid_input(): - response = client.put( - "/environments/123", json={"latitude": "foo", "longitude": "bar"} - ) - assert response.status_code == 422 - - -def test_update_env_not_found(stub_env): - with patch.object( - EnvController, - "update_env_by_id", - side_effect=HTTPException( - status_code=404, detail="Environment not found" - ), - ): - response = client.put("/environments/123", json=stub_env) - assert response.status_code == 404 - assert response.json() == {"detail": "Environment not found"} - - -def test_update_env_server_error(stub_env): - with patch.object( - EnvController, - "update_env_by_id", - side_effect=Exception("error"), - ): - with pytest.raises(Exception): - response = client.put("/environments/123", json=stub_env) - assert response.status_code == 500 - assert response.json() == { - "detail": "Failed to update environment: error" - } - - -def test_delete_env(): - with patch.object( - EnvController, - "delete_env_by_id", - return_value=EnvDeleted(env_id="123"), - ) as mock_delete_env: - response = client.delete("/environments/123") - assert response.status_code == 200 - assert response.json() == { - "env_id": "123", - "message": "Environment successfully deleted", - } - mock_delete_env.assert_called_once_with("123") - - -def test_delete_env_not_found(): - with patch.object( - EnvController, - "delete_env_by_id", - return_value=EnvDeleted(env_id="123"), - ) as mock_delete_env: - response = client.delete("/environments/123") - assert response.status_code == 200 - assert response.json() == { - "env_id": "123", - "message": "Environment successfully deleted", - } - mock_delete_env.assert_called_once_with("123") - - -def test_delete_env_server_error(): - with patch.object( - EnvController, - "delete_env_by_id", - side_effect=Exception("error"), - ): - with pytest.raises(Exception): - response = client.delete("/environments/123") - assert response.status_code == 500 - assert response.json() == { - "detail": "Failed to delete environment: error" - } - - -def test_simulate_env(stub_env_summary): - with patch.object( - EnvController, - "simulate_env", - return_value=EnvSummary(**stub_env_summary), - ) as mock_simulate_env: - response = client.get("/environments/123/summary") - assert response.status_code == 200 - assert response.json() == stub_env_summary - mock_simulate_env.assert_called_once_with("123") - - -def test_simulate_env_not_found(): - with patch.object( - EnvController, - "simulate_env", - side_effect=HTTPException( - status_code=404, detail="Environment not found" - ), - ) as mock_simulate_env: - response = client.get("/environments/123/summary") - assert response.status_code == 404 - assert response.json() == {"detail": "Environment not found"} - mock_simulate_env.assert_called_once_with("123") - - -def test_simulate_env_server_error(): - with patch.object( - EnvController, - "simulate_env", - side_effect=Exception("error"), - ): - with pytest.raises(Exception): - response = client.get("/environments/123/summary") - assert response.status_code == 500 - assert response.json() == { - "detail": "Failed to simulate environment: error" - } - - -def test_read_rocketpy_env(): - with patch.object( - EnvController, "get_rocketpy_env_binary", return_value=b'rocketpy' - ) as mock_read_rocketpy_env: - response = client.get("/environments/123/rocketpy") - assert response.status_code == 203 - assert response.content == b'rocketpy' - assert response.headers["content-type"] == "application/octet-stream" - mock_read_rocketpy_env.assert_called_once_with("123") - - -def test_read_rocketpy_env_not_found(): - with patch.object( - EnvController, - "get_rocketpy_env_binary", - side_effect=HTTPException( - status_code=404, detail="Environment not found" - ), - ) as mock_read_rocketpy_env: - response = client.get("/environments/123/rocketpy") - assert response.status_code == 404 - assert response.json() == {"detail": "Environment not found"} - mock_read_rocketpy_env.assert_called_once_with("123") - - -def test_read_rocketpy_env_server_error(): - with patch.object( - EnvController, - "get_rocketpy_env_binary", - side_effect=Exception("error"), - ): - with pytest.raises(Exception): - response = client.get("/environments/123/rocketpy") - assert response.status_code == 500 - assert response.json() == { - "detail": "Failed to read rocketpy environment: error" - } diff --git a/tests/test_routes/test_environments_route.py b/tests/test_routes/test_environments_route.py new file mode 100644 index 0000000..a32bf48 --- /dev/null +++ b/tests/test_routes/test_environments_route.py @@ -0,0 +1,266 @@ +from unittest.mock import patch +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException, status +from lib.models.environment import Env +from lib.controllers.environment import EnvController +from lib.views.environment import ( + EnvCreated, + EnvUpdated, + EnvDeleted, + EnvSummary, +) +from lib import app + +client = TestClient(app) + + +@pytest.fixture +def stub_env(): + env = Env(latitude=0, longitude=0) + env_json = env.model_dump_json() + return json.loads(env_json) + + +@pytest.fixture +def stub_env_summary(): + env_summary = EnvSummary() + env_summary_json = env_summary.model_dump_json() + return json.loads(env_summary_json) + + +def test_create_env(stub_env): + with patch.object( + EnvController, 'create_env', return_value=EnvCreated(env_id='123') + ) as mock_create_env: + response = client.post('/environments/', json=stub_env) + assert response.status_code == 200 + assert response.json() == { + 'env_id': '123', + 'message': 'Environment successfully created', + } + mock_create_env.assert_called_once_with(Env(**stub_env)) + + +def test_create_env_optional_params(): + test_object = { + 'latitude': 0, + 'longitude': 0, + 'elevation': 1, + 'atmospheric_model_type': 'STANDARD_ATMOSPHERE', + 'atmospheric_model_file': None, + 'date': '2021-01-01T00:00:00', + } + with patch.object( + EnvController, 'create_env', return_value=EnvCreated(env_id='123') + ) as mock_create_env: + response = client.post('/environments/', json=test_object) + assert response.status_code == 200 + assert response.json() == { + 'env_id': '123', + 'message': 'Environment successfully created', + } + mock_create_env.assert_called_once_with(Env(**test_object)) + + +def test_create_env_invalid_input(): + response = client.post( + '/environments/', json={'latitude': 'foo', 'longitude': 'bar'} + ) + assert response.status_code == 422 + + +def test_create_env_server_error(stub_env): + with patch.object( + EnvController, + 'create_env', + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.post('/environments/', json=stub_env) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_env(stub_env): + with patch.object( + EnvController, 'get_env_by_id', return_value=Env(**stub_env) + ) as mock_read_env: + response = client.get('/environments/123') + assert response.status_code == 200 + assert response.json() == stub_env + mock_read_env.assert_called_once_with('123') + + +def test_read_env_not_found(): + with patch.object( + EnvController, + 'get_env_by_id', + side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), + ) as mock_read_env: + response = client.get('/environments/123') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_read_env.assert_called_once_with('123') + + +def test_read_env_server_error(): + with patch.object( + EnvController, + 'get_env_by_id', + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.get('/environments/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_update_env(stub_env): + with patch.object( + EnvController, + 'update_env_by_id', + return_value=EnvUpdated(env_id='123'), + ) as mock_update_env: + response = client.put('/environments/123', json=stub_env) + assert response.status_code == 200 + assert response.json() == { + 'env_id': '123', + 'message': 'Environment successfully updated', + } + mock_update_env.assert_called_once_with('123', Env(**stub_env)) + + +def test_update_env_invalid_input(): + response = client.put( + '/environments/123', json={'latitude': 'foo', 'longitude': 'bar'} + ) + assert response.status_code == 422 + + +def test_update_env_not_found(stub_env): + with patch.object( + EnvController, + 'update_env_by_id', + side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), + ): + response = client.put('/environments/123', json=stub_env) + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + + +def test_update_env_server_error(stub_env): + with patch.object( + EnvController, + 'update_env_by_id', + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.put('/environments/123', json=stub_env) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_delete_env(): + with patch.object( + EnvController, + 'delete_env_by_id', + return_value=EnvDeleted(env_id='123'), + ) as mock_delete_env: + response = client.delete('/environments/123') + assert response.status_code == 200 + assert response.json() == { + 'env_id': '123', + 'message': 'Environment successfully deleted', + } + mock_delete_env.assert_called_once_with('123') + + +def test_delete_env_server_error(): + with patch.object( + EnvController, + 'delete_env_by_id', + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.delete('/environments/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_simulate_env(stub_env_summary): + with patch.object( + EnvController, + 'simulate_env', + return_value=EnvSummary(**stub_env_summary), + ) as mock_simulate_env: + response = client.get('/environments/123/summary') + assert response.status_code == 200 + assert response.json() == stub_env_summary + mock_simulate_env.assert_called_once_with('123') + + +def test_simulate_env_not_found(): + with patch.object( + EnvController, + 'simulate_env', + side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), + ) as mock_simulate_env: + response = client.get('/environments/123/summary') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_simulate_env.assert_called_once_with('123') + + +def test_simulate_env_server_error(): + with patch.object( + EnvController, + 'simulate_env', + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.get('/environments/123/summary') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} + + +def test_read_rocketpy_env(): + with patch.object( + EnvController, 'get_rocketpy_env_binary', return_value=b'rocketpy' + ) as mock_read_rocketpy_env: + response = client.get('/environments/123/rocketpy') + assert response.status_code == 203 + assert response.content == b'rocketpy' + assert response.headers['content-type'] == 'application/octet-stream' + mock_read_rocketpy_env.assert_called_once_with('123') + + +def test_read_rocketpy_env_not_found(): + with patch.object( + EnvController, + 'get_rocketpy_env_binary', + side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND), + ) as mock_read_rocketpy_env: + response = client.get('/environments/123/rocketpy') + assert response.status_code == 404 + assert response.json() == {'detail': 'Not Found'} + mock_read_rocketpy_env.assert_called_once_with('123') + + +def test_read_rocketpy_env_server_error(): + with patch.object( + EnvController, + 'get_rocketpy_env_binary', + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.get('/environments/123/rocketpy') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'} diff --git a/tests/test_routes/test_motors_route.py b/tests/test_routes/test_motors_route.py new file mode 100644 index 0000000..b7cab89 --- /dev/null +++ b/tests/test_routes/test_motors_route.py @@ -0,0 +1,549 @@ +from unittest.mock import patch +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException, status +from lib.models.motor import ( + Motor, + MotorKinds, + MotorTank, + TankFluids, + TankKinds, +) +from lib.controllers.motor import MotorController +from lib.views.motor import ( + 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_tank(): + tank = MotorTank( + geometry=[[(0, 0), 0]], + gas=TankFluids(name='gas', density=0), + liquid=TankFluids(name='liquid', density=0), + flux_time=(0, 0), + position=0, + discretize=0, + name='tank', + ) + tank_json = tank.model_dump_json() + return json.loads(tank_json) + + +@pytest.fixture +def stub_level_tank(stub_tank): + stub_tank.update({'tank_kind': TankKinds.LEVEL, 'liquid_height': 0}) + return stub_tank + + +@pytest.fixture +def stub_mass_flow_tank(stub_tank): + stub_tank.update( + { + 'tank_kind': TankKinds.MASS_FLOW, + 'gas_mass_flow_rate_in': 0, + 'gas_mass_flow_rate_out': 0, + 'liquid_mass_flow_rate_in': 0, + 'liquid_mass_flow_rate_out': 0, + 'initial_liquid_mass': 0, + 'initial_gas_mass': 0, + } + ) + return stub_tank + + +@pytest.fixture +def stub_ullage_tank(stub_tank): + stub_tank.update({'tank_kind': TankKinds.ULLAGE, 'ullage': 0}) + return stub_tank + + +@pytest.fixture +def stub_mass_tank(stub_tank): + stub_tank.update( + {'tank_kind': TankKinds.MASS, 'liquid_mass': 0, 'gas_mass': 0} + ) + return stub_tank + + +@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(stub_motor): + stub_motor.update( + { + '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=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_generic_motor(stub_motor): + stub_motor.update( + { + 'chamber_radius': 0, + 'chamber_height': 0, + 'chamber_position': 0, + 'propellant_initial_mass': 0, + 'nozzle_position': 0, + } + ) + 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': 'GENERIC'} + ) + assert response.status_code == 200 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.GENERIC) + mock_create_motor.assert_called_once_with(Motor(**stub_motor)) + + +def test_create_liquid_motor_level_tank(stub_motor, stub_level_tank): + stub_motor.update({'tanks': [stub_level_tank]}) + 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': 'LIQUID'} + ) + assert response.status_code == 200 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) + mock_create_motor.assert_called_once_with(Motor(**stub_motor)) + + +def test_create_liquid_motor_mass_flow_tank(stub_motor, stub_mass_flow_tank): + stub_motor.update({'tanks': [stub_mass_flow_tank]}) + 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': 'LIQUID'} + ) + assert response.status_code == 200 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) + mock_create_motor.assert_called_once_with(Motor(**stub_motor)) + + +def test_create_liquid_motor_ullage_tank(stub_motor, stub_ullage_tank): + stub_motor.update({'tanks': [stub_ullage_tank]}) + 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': 'LIQUID'} + ) + assert response.status_code == 200 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) + mock_create_motor.assert_called_once_with(Motor(**stub_motor)) + + +def test_create_liquid_motor_mass_tank(stub_motor, stub_mass_tank): + stub_motor.update({'tanks': [stub_mass_tank]}) + 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': 'LIQUID'} + ) + assert response.status_code == 200 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.LIQUID) + mock_create_motor.assert_called_once_with(Motor(**stub_motor)) + + +def test_create_hybrid_motor(stub_motor, stub_level_tank): + stub_motor.update( + { + 'grain_number': 0, + 'grain_density': 0, + 'grain_outer_radius': 0, + 'grain_initial_inner_radius': 0, + 'grain_initial_height': 0, + 'grains_center_of_mass_position': 0, + 'grain_separation': 0, + 'throat_radius': 0, + 'tanks': [stub_level_tank], + } + ) + 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_solid_motor(stub_motor): + stub_motor.update( + { + 'grain_number': 0, + 'grain_density': 0, + 'grain_outer_radius': 0, + 'grain_initial_inner_radius': 0, + 'grain_initial_height': 0, + 'grains_center_of_mass_position': 0, + 'grain_separation': 0, + } + ) + 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': 'SOLID'} + ) + assert response.status_code == 200 + assert response.json() == { + 'motor_id': '123', + 'message': 'Motor successfully created', + } + mock_set_motor_kind.assert_called_once_with(MotorKinds.SOLID) + mock_create_motor.assert_called_once_with(Motor(**stub_motor)) + + +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=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.post( + '/motors/', json=stub_motor, params={'motor_kind': 'HYBRID'} + ) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server 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=status.HTTP_404_NOT_FOUND), + ) 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=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.get('/motors/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server 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=status.HTTP_404_NOT_FOUND), + ): + 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=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.put( + '/motors/123', json=stub_motor, params={'motor_kind': 'HYBRID'} + ) + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server 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_server_error(): + with patch.object( + MotorController, + 'delete_motor_by_id', + side_effect=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.delete('/motors/123') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server 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=status.HTTP_404_NOT_FOUND), + ) 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=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.get('/motors/123/summary') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server 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=status.HTTP_404_NOT_FOUND), + ) 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=HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ), + ): + response = client.get('/motors/123/rocketpy') + assert response.status_code == 500 + assert response.json() == {'detail': 'Internal Server Error'}