Skip to content

Commit

Permalink
Add store tests #11
Browse files Browse the repository at this point in the history
  • Loading branch information
joelvdavies committed Sep 24, 2024
1 parent 34be34f commit 98f3e41
Show file tree
Hide file tree
Showing 10 changed files with 372 additions and 4 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
python -m pip install --upgrade pip
python -m pip install .[code-analysis]
- name: Run pylint
run: pylint object_storage_api
run: pylint object_storage_api test

unit-tests:
name: Unit Tests
Expand Down
4 changes: 2 additions & 2 deletions object_storage_api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ API__ALLOWED_CORS_METHODS=["*"]
DATABASE__PROTOCOL=mongodb
DATABASE__USERNAME=root
DATABASE__PASSWORD=example
DATABASE__HOST_AND_OPTIONS="localhost:27017/?authMechanism=SCRAM-SHA-256&authSource=admin"
DATABASE__HOST_AND_OPTIONS=localhost:27017/?authMechanism=SCRAM-SHA-256&authSource=admin
DATABASE__NAME=object-storage
OBJECT_STORAGE__ENDPOINT_URL="http://minio:9000"
OBJECT_STORAGE__ENDPOINT_URL=http://minio:9000
OBJECT_STORAGE__ACCESS_KEY=root
OBJECT_STORAGE__SECRET_ACCESS_KEY=example_password
OBJECT_STORAGE__BUCKET_NAME=object-storage
Expand Down
32 changes: 32 additions & 0 deletions test/mock_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
Mock data for use in tests.
Names should ideally be descriptive enough to recognise what they are without looking at the data itself.
Letters may be appended in places to indicate the data is of the same type, but has different specific values
to others.
_POST_DATA - Is for a `PostSchema` schema.
_IN_DATA - Is for an `In` model.
_GET_DATA - Is for an entity schema - Used in assertions for e2e tests.
_DATA - Is none of the above - likely to be used in post requests as they are likely identical, only with some ids
missing so that they can be added later e.g. for pairing up units that aren't known before hand.
"""

from bson import ObjectId

# ---------------------------- ATTACHMENTS -----------------------------

# All values

ATTACHMENT_POST_DATA_ALL_VALUES = {
"entity_id": str(ObjectId()),
"file_name": "report.pdf",
"title": "Report Title",
"description": "A damage report.",
}

ATTACHMENT_IN_DATA_ALL_VALUES = {
**ATTACHMENT_POST_DATA_ALL_VALUES,
"id": str(ObjectId()),
"object_key": "attachments/65df5ee771892ddcc08bd28f/65e0a624d64aaae884abaaee",
}
19 changes: 18 additions & 1 deletion test/pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,19 @@
[pytest]
asyncio_mode=auto
asyncio_mode=auto
env =
API__TITLE=Object Storage Service API
API__DESCRIPTION=This is the API for the Object Storage Service
API__ROOT_PATH=
API__ALLOWED_CORS_HEADERS=["*"]
API__ALLOWED_CORS_ORIGINS=["*"]
API__ALLOWED_CORS_METHODS=["*"]
DATABASE__PROTOCOL=mongodb
DATABASE__USERNAME=root
DATABASE__PASSWORD=example
DATABASE__HOST_AND_OPTIONS=localhost:27017/?authMechanism=SCRAM-SHA-256&authSource=admin
DATABASE__NAME=test-object-storage
OBJECT_STORAGE__ENDPOINT_URL=http://minio:9000
OBJECT_STORAGE__ACCESS_KEY=root
OBJECT_STORAGE__SECRET_ACCESS_KEY=example_password
OBJECT_STORAGE__BUCKET_NAME=test-object-storage
OBJECT_STORAGE__PRESIGNED_URL_EXPIRY=1800
Empty file.
76 changes: 76 additions & 0 deletions test/unit/repositories/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""
Module for providing common test configuration, test fixtures, and helper functions.
"""

from unittest.mock import Mock

import pytest
from bson import ObjectId
from pymongo.collection import Collection
from pymongo.database import Database
from pymongo.results import InsertOneResult

from object_storage_api.repositories.attachment import AttachmentRepo


@pytest.fixture(name="database_mock")
def fixture_database_mock() -> Mock:
"""
Fixture to create a mock of the MongoDB database dependency and its collections.
:return: Mocked MongoDB database instance with the mocked collections.
"""
database_mock = Mock(Database)
database_mock.attachments = Mock(Collection)
return database_mock


@pytest.fixture(name="attachment_repository")
def fixture_item_repository(database_mock: Mock) -> AttachmentRepo:
"""
Fixture to create a `AttachmentRepo` instance with a mocked Database dependency.
:param database_mock: Mocked MongoDB database instance.
:return: `AttachmentRepo` instance with the mocked dependency.
"""
return AttachmentRepo(database_mock)


class RepositoryTestHelpers:
"""
A utility class containing common helper methods for the repository tests.
This class provides a set of static methods that encapsulate common functionality frequently used in the repository
tests.
"""

@staticmethod
def mock_insert_one(collection_mock: Mock, inserted_id: ObjectId) -> None:
"""
Mock the `insert_one` method of the MongoDB database collection mock to return an `InsertOneResult` object. The
passed `inserted_id` value is returned as the `inserted_id` attribute of the `InsertOneResult` object, enabling
for the code that relies on the `inserted_id` value to work.
:param collection_mock: Mocked MongoDB database collection instance.
:param inserted_id: The `ObjectId` value to be assigned to the `inserted_id` attribute of the `InsertOneResult`
object
"""
insert_one_result_mock = Mock(InsertOneResult)
insert_one_result_mock.inserted_id = inserted_id
insert_one_result_mock.acknowledged = True
collection_mock.insert_one.return_value = insert_one_result_mock

@staticmethod
def mock_find_one(collection_mock: Mock, document: dict | None) -> None:
"""
Mocks the `find_one` method of the MongoDB database collection mock to return a specific document.
:param collection_mock: Mocked MongoDB database collection instance.
:param document: The document to be returned by the `find_one` method.
"""
if collection_mock.find_one.side_effect is None:
collection_mock.find_one.side_effect = [document]
else:
documents = list(collection_mock.find_one.side_effect)
documents.append(document)
collection_mock.find_one.side_effect = documents
88 changes: 88 additions & 0 deletions test/unit/repositories/test_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Unit tests for the `AttachmentRepo` repository.
"""

from test.mock_data import ATTACHMENT_IN_DATA_ALL_VALUES
from test.unit.repositories.conftest import RepositoryTestHelpers
from unittest.mock import MagicMock, Mock

import pytest

from object_storage_api.models.attachment import AttachmentIn, AttachmentOut
from object_storage_api.repositories.attachment import AttachmentRepo


class AttachmentRepoDSL:
"""Base class for `AttachmentRepo` unit tests."""

mock_database: Mock
attachment_repository: AttachmentRepo
attachments_collection: Mock

mock_session = MagicMock()

@pytest.fixture(autouse=True)
def setup(self, database_mock):
"""Setup fixtures"""

self.mock_database = database_mock
self.attachment_repository = AttachmentRepo(database_mock)
self.attachments_collection = database_mock.attachments


class CreateDSL(AttachmentRepoDSL):
"""Base class for `create` tests."""

_attachment_in: AttachmentIn
_expected_attachment_out: AttachmentOut
_created_attachment: AttachmentOut
_create_exception: pytest.ExceptionInfo

def mock_create(
self,
attachment_in_data: dict,
) -> None:
"""
Mocks database methods appropriately to test the `create` repo method.
:param attachment_in_data: Dictionary containing the attachment data as would be required for a `AttachmentIn`
database model (i.e. no created and modified times required).
"""

# Pass through `AttachmentIn` first as need creation and modified times
self._attachment_in = AttachmentIn(**attachment_in_data)

self._expected_attachment_out = AttachmentOut(**self._attachment_in.model_dump())

RepositoryTestHelpers.mock_insert_one(self.attachments_collection, self._attachment_in.id)
RepositoryTestHelpers.mock_find_one(
self.attachments_collection, {**self._attachment_in.model_dump(), "_id": self._attachment_in.id}
)

def call_create(self) -> None:
"""Calls the `AttachmentRepo` `create` method with the appropriate data from a prior call to `mock_create`."""

self._created_attachment = self.attachment_repository.create(self._attachment_in, session=self.mock_session)

def check_create_success(self) -> None:
"""Checks that a prior call to `call_create` worked as expected."""

self.attachments_collection.insert_one.assert_called_once_with(
self._attachment_in.model_dump(by_alias=True), session=self.mock_session
)
self.attachments_collection.find_one.assert_called_once_with(
{"_id": self._attachment_in.id}, session=self.mock_session
)

assert self._created_attachment == self._expected_attachment_out


class TestCreate(CreateDSL):
"""Tests for creating an attachment."""

def test_create(self):
"""Test creating an attachment."""

self.mock_create(ATTACHMENT_IN_DATA_ALL_VALUES)
self.call_create()
self.check_create_success()
Empty file added test/unit/stores/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions test/unit/stores/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
Module for providing common test configuration, test fixtures, and helper functions.
"""

from datetime import datetime, timezone
from unittest.mock import patch

import pytest

MODEL_MIXINS_FIXED_DATETIME_NOW = datetime(2024, 2, 16, 14, 0, 0, 0, tzinfo=timezone.utc)


@pytest.fixture(name="model_mixins_datetime_now_mock")
def fixture_model_mixins_datetime_now_mock():
"""
Fixture that mocks the `datetime.now` method in the `inventory_management_system_api.models.mixins` module.
"""
with patch("object_storage_api.models.mixins.datetime") as mock_datetime:
mock_datetime.now.return_value = MODEL_MIXINS_FIXED_DATETIME_NOW
yield mock_datetime
135 changes: 135 additions & 0 deletions test/unit/stores/test_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""
Unit tests for the `AttachmentStore` store.
"""

from test.mock_data import ATTACHMENT_POST_DATA_ALL_VALUES
from unittest.mock import MagicMock, patch

import pytest
from bson import ObjectId

from object_storage_api.core.exceptions import InvalidObjectIdError
from object_storage_api.core.object_store import object_storage_config
from object_storage_api.models.attachment import AttachmentIn
from object_storage_api.schemas.attachment import AttachmentPostSchema
from object_storage_api.stores.attachment import AttachmentStore


class AttachmentStoreDSL:
"""Base class for `AttachmentStore` unit tests."""

mock_s3_client: MagicMock
mock_object_id: MagicMock
attachment_store: AttachmentStore

@pytest.fixture(autouse=True)
def setup(
self,
# Ensures all created and modified times are mocked throughout
# pylint: disable=unused-argument
model_mixins_datetime_now_mock,
):
"""Setup fixtures"""

with patch("object_storage_api.stores.attachment.s3_client") as s3_client_mock:
with patch("object_storage_api.stores.attachment.ObjectId") as object_id_mock:
self.mock_s3_client = s3_client_mock
self.mock_object_id = object_id_mock
self.attachment_store = AttachmentStore()
yield


class CreateDSL(AttachmentStoreDSL):
"""Base class for `create` tests."""

_attachment_post: AttachmentPostSchema
_expected_attachment_in: AttachmentIn
_expected_url: str
_created_attachment_in: AttachmentIn
_generated_url: str
_create_exception: pytest.ExceptionInfo

def mock_create(self, attachment_post_data: dict) -> None:
"""
Mocks object store methods appropriately to test the `create` store method.
:param attachment_post_data: Dictionary containing the attachment data as would be required for an
`AttachmentPost` schema.
"""
self._attachment_post = AttachmentPostSchema(**attachment_post_data)

attachment_id = ObjectId()
self.mock_object_id.return_value = attachment_id

expected_object_key = f"attachments/{self._attachment_post.entity_id}/{attachment_id}"

# Mock presigned url generation
self._expected_url = "http://test-url.com"
self.mock_s3_client.generate_presigned_url.return_value = self._expected_url

# Expected model data with the object key defined (Ignore if invalid to avoid a premature error)
if self._attachment_post.entity_id != "invalid-id":
self._expected_attachment_in = AttachmentIn(
**self._attachment_post.model_dump(), id=str(attachment_id), object_key=expected_object_key
)

def call_create(self) -> None:
"""Calls the `AttachmentStore` `create` method with the appropriate data from a prior call to `mock_create`."""

self._created_attachment_in, self._generated_url = self.attachment_store.create(self._attachment_post)

def call_create_expecting_error(self, error_type: type[BaseException]) -> None:
"""
Calls the `AttachmentStore` `create` method with the appropriate data from a prior call to `mock_create`
while expecting an error to be raised.
:param error_type: Expected exception to be raised.
"""

with pytest.raises(error_type) as exc:
self.attachment_store.create(self._attachment_post)
self._create_exception = exc

def check_create_success(self) -> None:
"""Checks that a prior call to `call_create` worked as expected."""

self.mock_s3_client.generate_presigned_url.assert_called_once_with(
"put_object",
Params={
"Bucket": object_storage_config.bucket_name.get_secret_value(),
"Key": self._expected_attachment_in.object_key,
},
ExpiresIn=object_storage_config.presigned_url_expiry,
)

# Cannot know the expected creation and modified time here, so ignore in comparison
assert self._created_attachment_in == self._expected_attachment_in
assert self._generated_url == self._expected_url

def check_create_failed_with_exception(self, message: str) -> None:
"""
Checks that a prior call to `call_create_expecting_error` worked as expected, raising an exception
with the correct message.
:param message: Message of the raised exception.
"""

assert str(self._create_exception.value) == message


class TestCreate(CreateDSL):
"""Tests for creating an attachment."""

def test_create(self):
"""Test creating an attachment."""

self.mock_create(ATTACHMENT_POST_DATA_ALL_VALUES)
self.call_create()
self.check_create_success()

def test_create_with_invalid_entity_id(self):
"""Test creating an attachment with an invalid `entity_id`."""

self.mock_create({**ATTACHMENT_POST_DATA_ALL_VALUES, "entity_id": "invalid-id"})
self.call_create_expecting_error(InvalidObjectIdError)
self.check_create_failed_with_exception("Invalid ObjectId value 'invalid-id'")

0 comments on commit 98f3e41

Please sign in to comment.