diff --git a/inventory_management_system_api/schemas/setting.py b/inventory_management_system_api/schemas/setting.py index a18bf957..5c181f98 100644 --- a/inventory_management_system_api/schemas/setting.py +++ b/inventory_management_system_api/schemas/setting.py @@ -2,7 +2,7 @@ Module for defining the API schema models for representing settings. """ -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, conlist, field_validator from inventory_management_system_api.schemas.usage_status import UsageStatusSchema @@ -28,13 +28,13 @@ class SparesDefinitionPutSchema(BaseModel): Schema model for a spares definition update request. """ - usage_statuses: list[SparesDefinitionPutUsageStatusSchema] = Field( + usage_statuses: conlist(SparesDefinitionPutUsageStatusSchema, min_length=1) = Field( description="Usage statuses that classify items as a spare." ) @field_validator("usage_statuses") @classmethod - def validate_allowed_values( + def validate_usage_statuses( cls, usage_statuses: list[SparesDefinitionPutUsageStatusSchema] ) -> list[SparesDefinitionPutUsageStatusSchema]: """ @@ -42,17 +42,18 @@ def validate_allowed_values( Ensures the `usage_statuses` dont contain any duplicate IDs. - :param allowed_values: The value of the `allowed_values` field. + :param usage_statuses: The value of the `usage_statuses` field. :param info: Validation info from pydantic. - :return: The value of the `allowed_values` field. + :return: The value of the `usage_statuses` field. """ + # Prevent duplicates seen_usage_status_ids = set() - for usage_status in usage_statuses: if usage_status.id in seen_usage_status_ids: raise ValueError(f"usage_statuses contains a duplicate ID: {usage_status.id}") seen_usage_status_ids.add(usage_status.id) + return usage_statuses diff --git a/test/mock_data.py b/test/mock_data.py index 5c397390..c4426ce2 100644 --- a/test/mock_data.py +++ b/test/mock_data.py @@ -60,6 +60,10 @@ "code": "new", } +USAGE_STATUS_OUT_DATA_NEW = UsageStatusOut( + **UsageStatusIn(**USAGE_STATUS_IN_DATA_NEW).model_dump(), _id="673f07237da637045aaf5747" +).model_dump() + USAGE_STATUS_GET_DATA_NEW = { **USAGE_STATUS_POST_DATA_NEW, **CREATED_MODIFIED_GET_DATA_EXPECTED, @@ -787,12 +791,12 @@ # --------------------------------- SETTINGS --------------------------------- -# Spares definition -SETTING_SPARES_DEFINITION_IN_DATA = {"usage_statuses": [{"id": str(ObjectId())}]} +# Spares definition, New +SETTING_SPARES_DEFINITION_IN_DATA_NEW = {"usage_statuses": [{"id": USAGE_STATUS_OUT_DATA_NEW["id"]}]} -SETTING_SPARES_DEFINITION_OUT_DATA = { +SETTING_SPARES_DEFINITION_OUT_DATA_NEW = { "_id": SparesDefinitionOut.SETTING_ID, - "usage_statuses": [ - UsageStatusOut(**UsageStatusIn(**USAGE_STATUS_IN_DATA_NEW).model_dump(), _id=str(ObjectId())).model_dump() - ], + "usage_statuses": [USAGE_STATUS_OUT_DATA_NEW], } + +SETTING_SPARES_DEFINITION_PUT_DATA_NEW = SETTING_SPARES_DEFINITION_IN_DATA_NEW diff --git a/test/unit/repositories/test_setting.py b/test/unit/repositories/test_setting.py index 91a8edca..0e97176f 100644 --- a/test/unit/repositories/test_setting.py +++ b/test/unit/repositories/test_setting.py @@ -2,7 +2,7 @@ Unit tests for the `SettingRepo` repository. """ -from test.mock_data import SETTING_SPARES_DEFINITION_IN_DATA, SETTING_SPARES_DEFINITION_OUT_DATA +from test.mock_data import SETTING_SPARES_DEFINITION_IN_DATA_NEW, SETTING_SPARES_DEFINITION_OUT_DATA_NEW from test.unit.repositories.conftest import RepositoryTestHelpers from typing import ClassVar, Optional, Type from unittest.mock import MagicMock, Mock @@ -129,7 +129,7 @@ def test_get_non_existent(self): def test_get_spares_definition(self): """Test getting the spares definition setting.""" - self.mock_get(SparesDefinitionOut, SETTING_SPARES_DEFINITION_OUT_DATA) + self.mock_get(SparesDefinitionOut, SETTING_SPARES_DEFINITION_OUT_DATA_NEW) self.call_get(SparesDefinitionOut) self.check_get_success() @@ -212,8 +212,8 @@ def test_upsert_spares_definition(self): """Test upserting the spares definition setting.""" self.mock_upsert( - SETTING_SPARES_DEFINITION_IN_DATA, - SETTING_SPARES_DEFINITION_OUT_DATA, + SETTING_SPARES_DEFINITION_IN_DATA_NEW, + SETTING_SPARES_DEFINITION_OUT_DATA_NEW, SparesDefinitionIn, SparesDefinitionOut, ) diff --git a/test/unit/services/conftest.py b/test/unit/services/conftest.py index 685674af..10b419a4 100644 --- a/test/unit/services/conftest.py +++ b/test/unit/services/conftest.py @@ -13,6 +13,7 @@ from inventory_management_system_api.models.catalogue_item import CatalogueItemOut, PropertyIn from inventory_management_system_api.models.item import ItemOut from inventory_management_system_api.models.manufacturer import ManufacturerOut +from inventory_management_system_api.models.setting import SparesDefinitionOut from inventory_management_system_api.models.system import SystemOut from inventory_management_system_api.models.unit import UnitOut from inventory_management_system_api.models.usage_status import UsageStatusOut @@ -20,6 +21,7 @@ from inventory_management_system_api.repositories.catalogue_item import CatalogueItemRepo from inventory_management_system_api.repositories.item import ItemRepo from inventory_management_system_api.repositories.manufacturer import ManufacturerRepo +from inventory_management_system_api.repositories.setting import SettingRepo from inventory_management_system_api.repositories.system import SystemRepo from inventory_management_system_api.repositories.unit import UnitRepo from inventory_management_system_api.repositories.usage_status import UsageStatusRepo @@ -31,6 +33,7 @@ from inventory_management_system_api.services.catalogue_item import CatalogueItemService from inventory_management_system_api.services.item import ItemService from inventory_management_system_api.services.manufacturer import ManufacturerService +from inventory_management_system_api.services.setting import SettingService from inventory_management_system_api.services.system import SystemService from inventory_management_system_api.services.unit import UnitService from inventory_management_system_api.services.usage_status import UsageStatusService @@ -106,6 +109,16 @@ def fixture_usage_status_repository_mock() -> Mock: return Mock(UsageStatusRepo) +@pytest.fixture(name="setting_repository_mock") +def fixture_setting_repository_mock() -> Mock: + """ + Fixture to create a mock of the `SettingRepo` dependency. + + :return: Mocked `SettingRepo` instance. + """ + return Mock(SettingRepo) + + @pytest.fixture(name="catalogue_category_service") def fixture_catalogue_category_service( catalogue_category_repository_mock: Mock, unit_repository_mock: Mock @@ -172,8 +185,8 @@ def fixture_item_service( usage_status_repository_mock: Mock, ) -> ItemService: """ - Fixture to create an `ItemService` instance with mocked `ItemRepo`, `CatalogueItemRepo`, - `CatalogueCategoryRepo`, `SystemRepo` and `UsageStatusRepo` dependencies. + Fixture to create an `ItemService` instance with mocked `ItemRepo`, `CatalogueItemRepo`, `CatalogueCategoryRepo`, + `SystemRepo` and `UsageStatusRepo` dependencies. :param item_repository_mock: Mocked `ItemRepo` instance. :param catalogue_category_repository_mock: Mocked `CatalogueCategoryRepo` instance. @@ -192,7 +205,7 @@ def fixture_item_service( @pytest.fixture(name="manufacturer_service") def fixture_manufacturer_service(manufacturer_repository_mock: Mock) -> ManufacturerService: """ - Fixture to create a `ManufacturerService` instance with a mocked `ManufacturerRepo` + Fixture to create a `ManufacturerService` instance with a mocked `ManufacturerRepo` dependency. :param: manufacturer_repository_mock: Mocked `ManufacturerRepo` instance. :return: `ManufacturerService` instance with mocked dependency. @@ -203,8 +216,7 @@ def fixture_manufacturer_service(manufacturer_repository_mock: Mock) -> Manufact @pytest.fixture(name="system_service") def fixture_system_service(system_repository_mock: Mock) -> SystemService: """ - Fixture to create a `SystemService` instance with a mocked `SystemRepo` - dependencies. + Fixture to create a `SystemService` instance with a mocked `SystemRepo` dependency. :param system_repository_mock: Mocked `SystemRepo` instance. :return: `SystemService` instance with the mocked dependency. @@ -215,8 +227,7 @@ def fixture_system_service(system_repository_mock: Mock) -> SystemService: @pytest.fixture(name="unit_service") def fixture_unit_service(unit_repository_mock: Mock) -> UnitService: """ - Fixture to create a `UnitService` instance with a mocked `UnitRepo` - dependencies. + Fixture to create a `UnitService` instance with a mocked `UnitRepo` dependency. :param unit_repository_mock: Mocked `UnitRepo` instance. :return: `UnitService` instance with the mocked dependency. @@ -227,8 +238,7 @@ def fixture_unit_service(unit_repository_mock: Mock) -> UnitService: @pytest.fixture(name="usage_status_service") def fixture_usage_status_service(usage_status_repository_mock: Mock) -> UsageStatusService: """ - Fixture to create a `UsageStatusService` instance with a mocked `UsageStatusRepo` - dependencies. + Fixture to create a `UsageStatusService` instance with a mocked `UsageStatusRepo` dependency. :param usage_status_repository_mock: Mocked `UsageStatusRepo` instance. :return: `UsageStatusService` instance with the mocked dependency. @@ -236,6 +246,18 @@ def fixture_usage_status_service(usage_status_repository_mock: Mock) -> UsageSta return UsageStatusService(usage_status_repository_mock) +@pytest.fixture(name="setting_service") +def fixture_setting_service(setting_repository_mock: Mock, usage_status_repository_mock: Mock) -> SettingService: + """ + Fixture to create a `SettingService` instance with mocked `SettingRepo` and `UsageStatusRepo` dependencies. + + :param setting_repository_mock: Mocked `SettingRepo` instance. + :param usage_status_repository_mock: Mocked `UsageStatusRepo` instance. + :return: `SettingService` instance with the mocked dependency. + """ + return SettingService(setting_repository_mock, usage_status_repository_mock) + + class ServiceTestHelpers: """ A utility class containing common helper methods for the service tests. @@ -262,7 +284,9 @@ def mock_create( @staticmethod def mock_get( repository_mock: Mock, - repo_obj: Union[CatalogueCategoryOut, CatalogueItemOut, ItemOut, ManufacturerOut, SystemOut, UnitOut, None], + repo_obj: Union[ + CatalogueCategoryOut, CatalogueItemOut, ItemOut, ManufacturerOut, SystemOut, UnitOut, UsageStatusOut, None + ], ) -> None: """ Mock the `get` method of the repository mock to return a specific repository object. @@ -325,6 +349,17 @@ def mock_update( repository_mock.update.return_value = repo_obj + @staticmethod + def mock_upsert(repository_mock: Mock, repo_obj: SparesDefinitionOut) -> None: + """ + Mock the `upsert` method of the repository mock to return a repository object. + + :param repository_mock: Mocked repository instance. + :param repo_obj: The repository object to be returned by the `upsert` method. + """ + + repository_mock.upsert.return_value = repo_obj + class BaseCatalogueServiceDSL: """Base class providing utilities to any catalogue related service tests.""" diff --git a/test/unit/services/test_setting.py b/test/unit/services/test_setting.py new file mode 100644 index 00000000..96fc1743 --- /dev/null +++ b/test/unit/services/test_setting.py @@ -0,0 +1,137 @@ +""" +Unit tests for the `SettingService` service. +""" + +from test.mock_data import SETTING_SPARES_DEFINITION_PUT_DATA_NEW, USAGE_STATUS_OUT_DATA_NEW +from test.unit.services.conftest import ServiceTestHelpers +from typing import Optional +from unittest.mock import MagicMock, Mock, call + +import pytest + +from inventory_management_system_api.core.exceptions import MissingRecordError +from inventory_management_system_api.models.setting import SparesDefinitionIn, SparesDefinitionOut +from inventory_management_system_api.schemas.setting import SparesDefinitionPutSchema +from inventory_management_system_api.services.setting import SettingService + + +class SettingServiceDSL: + """Base class for `SettingService` unit tests.""" + + mock_setting_repository: Mock + mock_usage_status_repository: Mock + setting_service: SettingService + + @pytest.fixture(autouse=True) + def setup(self, setting_repository_mock, usage_status_repository_mock, setting_service): + """Setup fixtures""" + + self.mock_setting_repository = setting_repository_mock + self.mock_usage_status_repository = usage_status_repository_mock + self.setting_service = setting_service + + +class SetSparesDefinitionDSL(SettingServiceDSL): + """Base class for `set_spares_definition` tests.""" + + _spares_definition_put: SparesDefinitionPutSchema + _expected_spares_definition_in: SparesDefinitionIn + _expected_spares_definition_out: MagicMock + _set_spares_definition: MagicMock + _set_spares_definition_exception: pytest.ExceptionInfo + + def mock_set_spares_definition( + self, spares_definition_put_data: dict, usage_statuses_out_data: list[Optional[dict]] + ) -> None: + """ + Mocks repository methods appropriately to test the `set_spares_definition` service method. + + :param spares_definition_put_data: Dictionary containing the put data as would be required for a + `SparesDefinitionPutSchema` (i.e. no ID, code, or created and modified times + required). + :param usage_statuses_out_data: List where each element is either `None` or dictionaries containing the basic + usage status data as would be required for a `UsageStatusOut` database model. + (Should correspond to each of the usage status IDs given in the setting put + data.) + """ + + # Stored usage statuses + for i in range(0, len(spares_definition_put_data["usage_statuses"])): + ServiceTestHelpers.mock_get(self.mock_usage_status_repository, usage_statuses_out_data[i]) + + # Put schema + self._spares_definition_put = SparesDefinitionPutSchema(**spares_definition_put_data) + + # Expected input for the repository + self._expected_spares_definition_in = SparesDefinitionIn(**spares_definition_put_data) + + # Upserted setting + self._expected_spares_definition_out = MagicMock() + ServiceTestHelpers.mock_upsert(self.mock_setting_repository, self._expected_spares_definition_out) + + def call_set_spares_definition(self) -> None: + """Calls the `SettingService` `set_spares_definition` method with the appropriate data from a prior call to + `mock_set_spares_definition`.""" + + self._set_spares_definition = self.setting_service.set_spares_definition(self._spares_definition_put) + + def call_set_spares_definition_expecting_error(self, error_type: type[BaseException]) -> None: + """ + Calls the `SettingService` `set_spares_definition` method with the appropriate data from a prior call to + `mock_set_spares_definition` while expecting an error to be raised. + + :param error_type: Expected exception to be raised. + """ + + with pytest.raises(error_type) as exc: + self.setting_service.set_spares_definition(self._spares_definition_put) + self._set_spares_definition_exception = exc + + def check_set_spares_definition_success(self) -> None: + """Checks that a prior call to `call_set_spares_definition` worked as expected.""" + + # Ensure obtained all of the required usage statuses + self.mock_usage_status_repository.get.assert_has_calls( + # Pydantic Field confuses pylint + # pylint: disable=not-an-iterable + [call(usage_status.id) for usage_status in self._spares_definition_put.usage_statuses] + ) + + # Ensure upserted with expected data + self.mock_setting_repository.upsert.assert_called_once_with( + self._expected_spares_definition_in, SparesDefinitionOut + ) + + assert self._set_spares_definition == self._expected_spares_definition_out + + def check_set_spares_definition_failed_with_exception(self, message: str) -> None: + """ + Checks that a prior call to `call_set_spares_definition_expecting_error` worked as expected, raising an + exception with the correct message. + + :param message: Expected message of the raised exception. + """ + + self.mock_setting_repository.upsert.assert_not_called() + + assert str(self._set_spares_definition_exception.value) == message + + +class TestSetSpareDefinition(SetSparesDefinitionDSL): + """Tests for setting the spares definition.""" + + def test_set_spare_definition(self): + """Test setting the spares definition.""" + + self.mock_set_spares_definition(SETTING_SPARES_DEFINITION_PUT_DATA_NEW, [USAGE_STATUS_OUT_DATA_NEW]) + self.call_set_spares_definition() + self.check_set_spares_definition_success() + + def test_set_spare_definition_with_non_existent_usage_status_id(self): + """Test setting the spares definition with a non-existent usage status ID.""" + + self.mock_set_spares_definition(SETTING_SPARES_DEFINITION_PUT_DATA_NEW, [None]) + self.call_set_spares_definition_expecting_error(MissingRecordError) + self.check_set_spares_definition_failed_with_exception( + f"No usage status found with ID: {USAGE_STATUS_OUT_DATA_NEW['id']}" + ) diff --git a/test/unit/services/test_system.py b/test/unit/services/test_system.py index 137bd9e5..e1b18f2b 100644 --- a/test/unit/services/test_system.py +++ b/test/unit/services/test_system.py @@ -37,7 +37,7 @@ def setup( # pylint: disable=unused-argument model_mixins_datetime_now_mock, ): - """Setup fixtures""" + """Setup fixtures.""" self.mock_system_repository = system_repository_mock self.system_service = system_service @@ -322,8 +322,8 @@ def check_update_success(self) -> None: def check_update_failed_with_exception(self, message: str) -> None: """ - Checks that a prior call to `call_update_expecting_error` worked as expected, raising an exception - with the correct message. + Checks that a prior call to `call_update_expecting_error` worked as expected, raising an exception with the + correct message. :param message: Expected message of the raised exception. """