Skip to content

Commit

Permalink
Enforce at least one usage status and add SettingService unit tests #413
Browse files Browse the repository at this point in the history
  • Loading branch information
joelvdavies committed Nov 21, 2024
1 parent adbb663 commit d9085fb
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 29 deletions.
13 changes: 7 additions & 6 deletions inventory_management_system_api/schemas/setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,31 +28,32 @@ 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]:
"""
Validator for the `usage_statuses` field.
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}")

Check warning on line 54 in inventory_management_system_api/schemas/setting.py

View check run for this annotation

Codecov / codecov/patch

inventory_management_system_api/schemas/setting.py#L54

Added line #L54 was not covered by tests
seen_usage_status_ids.add(usage_status.id)

return usage_statuses


Expand Down
16 changes: 10 additions & 6 deletions test/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
8 changes: 4 additions & 4 deletions test/unit/repositories/test_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
)
Expand Down
55 changes: 45 additions & 10 deletions test/unit/services/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
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
from inventory_management_system_api.repositories.catalogue_category import CatalogueCategoryRepo
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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -227,15 +238,26 @@ 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.
"""
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.
Expand All @@ -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.
Expand Down Expand Up @@ -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."""
Expand Down
137 changes: 137 additions & 0 deletions test/unit/services/test_setting.py
Original file line number Diff line number Diff line change
@@ -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']}"
)
Loading

0 comments on commit d9085fb

Please sign in to comment.