From b1cc2262e3ae03070e453f48853d6237b7156deb Mon Sep 17 00:00:00 2001 From: Peter Schutt Date: Mon, 7 Nov 2022 20:19:47 +1000 Subject: [PATCH] chore(tests): adding coverage (#97) * chore(tests): tests for endpoint_decorator.py * chore(tests): tests for http.py * chore(tests): linting * chore(tests): tests for init_plugin.py * chore(tests): tests cleaning up various missing. --- src/starlite_saqlalchemy/init_plugin.py | 9 +- src/starlite_saqlalchemy/log/__init__.py | 2 +- .../repository/sqlalchemy.py | 2 + tests/unit/repository/test_abc.py | 23 ++++++ tests/unit/repository/test_sqlalchemy.py | 40 +++++++-- tests/unit/test_endpoint_decorator.py | 30 +++++++ tests/unit/test_http.py | 44 ++++++++++ tests/unit/test_init_plugin.py | 82 +++++++++++++++++++ tests/unit/test_orm.py | 9 ++ 9 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_endpoint_decorator.py create mode 100644 tests/unit/test_http.py create mode 100644 tests/unit/test_init_plugin.py diff --git a/src/starlite_saqlalchemy/init_plugin.py b/src/starlite_saqlalchemy/init_plugin.py index 80b34496..beb1a3e8 100644 --- a/src/starlite_saqlalchemy/init_plugin.py +++ b/src/starlite_saqlalchemy/init_plugin.py @@ -76,7 +76,6 @@ class PluginConfig(BaseModel): application. """ - # why isn't the callback defined here? worker_functions: list[Callable[..., Any] | tuple[str, Callable[..., Any]]] = [ (make_service_callback.__qualname__, make_service_callback) ] @@ -193,6 +192,7 @@ def __call__(self, app_config: AppConfig) -> AppConfig: self.configure_exception_handlers(app_config) self.configure_health_check(app_config) self.configure_logging(app_config) + self.configure_openapi(app_config) self.configure_response_class(app_config) self.configure_sentry(app_config) self.configure_sqlalchemy_plugin(app_config) @@ -208,8 +208,7 @@ def configure_after_exception(self, app_config: AppConfig) -> None: app_config: The Starlite application config object. """ if self.config.do_after_exception: - if not isinstance(app_config.after_exception, list): - app_config.after_exception = [app_config.after_exception] + app_config.after_exception = self._ensure_list(app_config.after_exception) app_config.after_exception.append(exceptions.after_exception_hook_handler) def configure_cache(self, app_config: AppConfig) -> None: @@ -221,7 +220,7 @@ def configure_cache(self, app_config: AppConfig) -> None: Args: app_config: The Starlite application config object. """ - if self.config.do_cache and app_config.cache_config is DEFAULT_CACHE_CONFIG: + if self.config.do_cache and app_config.cache_config == DEFAULT_CACHE_CONFIG: app_config.cache_config = cache.config def configure_collection_dependencies(self, app_config: AppConfig) -> None: @@ -300,7 +299,7 @@ def configure_openapi(self, app_config: AppConfig) -> None: Args: app_config: The Starlite application config object. """ - if self.config.do_openapi and app_config.openapi_config is DEFAULT_OPENAPI_CONFIG: + if self.config.do_openapi and app_config.openapi_config == DEFAULT_OPENAPI_CONFIG: app_config.openapi_config = openapi.config def configure_response_class(self, app_config: AppConfig) -> None: diff --git a/src/starlite_saqlalchemy/log/__init__.py b/src/starlite_saqlalchemy/log/__init__.py index 29560867..d1b7241c 100644 --- a/src/starlite_saqlalchemy/log/__init__.py +++ b/src/starlite_saqlalchemy/log/__init__.py @@ -67,7 +67,7 @@ def _make_filtering_bound_logger(min_level: int) -> type[structlog.types.Filteri class _WrappedFilteringBoundLogger(filtering_bound_logger): # type:ignore[misc,valid-type] async def alog(self: Any, level: int, event: str, *args: Any, **kw: Any) -> Any: """This method will exist in the next release of structlog.""" - if level < min_level: + if level < min_level: # pragma: no cover return None # pylint: disable=protected-access name = structlog._log_levels._LEVEL_TO_NAME[level] # pyright: ignore diff --git a/src/starlite_saqlalchemy/repository/sqlalchemy.py b/src/starlite_saqlalchemy/repository/sqlalchemy.py index 89f540f4..1d9d7592 100644 --- a/src/starlite_saqlalchemy/repository/sqlalchemy.py +++ b/src/starlite_saqlalchemy/repository/sqlalchemy.py @@ -109,6 +109,8 @@ async def list(self, *filters: FilterTypes, **kwargs: Any) -> list[ModelT]: self._filter_on_datetime_field(field_name, before, after) case CollectionFilter(field_name, values): self._filter_in_collection(field_name, values) + case _: + raise RepositoryException(f"Unexpected filter: {filter}") self._filter_select_by_kwargs(**kwargs) with wrap_sqlalchemy_exception(): diff --git a/tests/unit/repository/test_abc.py b/tests/unit/repository/test_abc.py index ea40da69..b49f6b2f 100644 --- a/tests/unit/repository/test_abc.py +++ b/tests/unit/repository/test_abc.py @@ -1,4 +1,7 @@ """Tests for the repository base class.""" +from __future__ import annotations + +from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest @@ -6,6 +9,9 @@ from starlite_saqlalchemy.repository.exceptions import RepositoryNotFoundException from starlite_saqlalchemy.testing.repository import GenericMockRepository +if TYPE_CHECKING: + from pytest import MonkeyPatch + def test_repository_check_not_found_raises() -> None: """Test `check_not_found()` raises if `None`.""" @@ -17,3 +23,20 @@ def test_repository_check_not_found_returns_item() -> None: """Test `check_not_found()` returns the item if not `None`.""" mock_item = MagicMock() assert GenericMockRepository.check_not_found(mock_item) is mock_item + + +def test_repository_get_id_attribute_value(monkeypatch: MonkeyPatch) -> None: + """Test id attribute value retrieval.""" + monkeypatch.setattr(GenericMockRepository, "id_attribute", "random_attribute") + mock = MagicMock() + mock.random_attribute = "this one" + assert GenericMockRepository.get_id_attribute_value(mock) == "this one" + + +def test_repository_set_id_attribute_value(monkeypatch: MonkeyPatch) -> None: + """Test id attribute value setter.""" + monkeypatch.setattr(GenericMockRepository, "id_attribute", "random_attribute") + mock = MagicMock() + mock.random_attribute = "this one" + mock = GenericMockRepository.set_id_attribute_value("no this one", mock) + assert mock.random_attribute == "no this one" diff --git a/tests/unit/repository/test_sqlalchemy.py b/tests/unit/repository/test_sqlalchemy.py index 5c80dfb0..70af2cd7 100644 --- a/tests/unit/repository/test_sqlalchemy.py +++ b/tests/unit/repository/test_sqlalchemy.py @@ -1,5 +1,7 @@ """Unit tests for the SQLAlchemy Repository implementation.""" # pylint: disable=protected-access,redefined-outer-name +from __future__ import annotations + from datetime import datetime from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, call @@ -63,7 +65,7 @@ async def test_sqlalchemy_repo_add(mock_repo: SQLAlchemyRepository) -> None: async def test_sqlalchemy_repo_delete( - mock_repo: SQLAlchemyRepository, monkeypatch: "MonkeyPatch" + mock_repo: SQLAlchemyRepository, monkeypatch: MonkeyPatch ) -> None: """Test expected method calls for delete operation.""" mock_instance = MagicMock() @@ -77,7 +79,7 @@ async def test_sqlalchemy_repo_delete( async def test_sqlalchemy_repo_get_member( - mock_repo: SQLAlchemyRepository, monkeypatch: "MonkeyPatch" + mock_repo: SQLAlchemyRepository, monkeypatch: MonkeyPatch ) -> None: """Test expected method calls for member get operation.""" mock_instance = MagicMock() @@ -92,7 +94,7 @@ async def test_sqlalchemy_repo_get_member( async def test_sqlalchemy_repo_list( - mock_repo: SQLAlchemyRepository, monkeypatch: "MonkeyPatch" + mock_repo: SQLAlchemyRepository, monkeypatch: MonkeyPatch ) -> None: """Test expected method calls for list operation.""" mock_instances = [MagicMock(), MagicMock()] @@ -107,7 +109,7 @@ async def test_sqlalchemy_repo_list( async def test_sqlalchemy_repo_list_with_pagination( - mock_repo: SQLAlchemyRepository, monkeypatch: "MonkeyPatch" + mock_repo: SQLAlchemyRepository, monkeypatch: MonkeyPatch ) -> None: """Test list operation with pagination.""" result_mock = MagicMock() @@ -121,7 +123,7 @@ async def test_sqlalchemy_repo_list_with_pagination( async def test_sqlalchemy_repo_list_with_before_after_filter( - mock_repo: SQLAlchemyRepository, monkeypatch: "MonkeyPatch" + mock_repo: SQLAlchemyRepository, monkeypatch: MonkeyPatch ) -> None: """Test list operation with BeforeAfter filter.""" field_name = "updated" @@ -138,7 +140,7 @@ async def test_sqlalchemy_repo_list_with_before_after_filter( async def test_sqlalchemy_repo_list_with_collection_filter( - mock_repo: SQLAlchemyRepository, monkeypatch: "MonkeyPatch" + mock_repo: SQLAlchemyRepository, monkeypatch: MonkeyPatch ) -> None: """Test behavior of list operation given CollectionFilter.""" field_name = "id" @@ -152,8 +154,14 @@ async def test_sqlalchemy_repo_list_with_collection_filter( getattr(mock_repo.model_type, field_name).in_.assert_called_once_with(values) +async def test_sqlalchemy_repo_unknown_filter_type_raises(mock_repo: SQLAlchemyRepository) -> None: + """Test that repo raises exception if list receives unknown filter type.""" + with pytest.raises(RepositoryException): + await mock_repo.list("not a filter") # type:ignore[arg-type] + + async def test_sqlalchemy_repo_update( - mock_repo: SQLAlchemyRepository, monkeypatch: "MonkeyPatch" + mock_repo: SQLAlchemyRepository, monkeypatch: MonkeyPatch ) -> None: """Test the sequence of repo calls for update operation.""" id_ = 3 @@ -203,3 +211,21 @@ def test_filter_in_collection_noop_if_collection_empty(mock_repo: SQLAlchemyRepo """Ensures we don't filter on an empty collection.""" mock_repo._filter_in_collection("id", []) mock_repo._select.where.assert_not_called() + + +@pytest.mark.parametrize( + ("before", "after"), + [ + (datetime.max, datetime.min), + (None, datetime.min), + (datetime.max, None), + ], +) +def test__filter_on_datetime_field( + before: datetime, after: datetime, mock_repo: SQLAlchemyRepository +) -> None: + """Test through branches of _filter_on_datetime_field()""" + field_mock = MagicMock() + field_mock.__gt__ = field_mock.__lt__ = lambda self, other: True + mock_repo.model_type.updated = field_mock + mock_repo._filter_on_datetime_field("updated", before, after) diff --git a/tests/unit/test_endpoint_decorator.py b/tests/unit/test_endpoint_decorator.py new file mode 100644 index 00000000..ac693d05 --- /dev/null +++ b/tests/unit/test_endpoint_decorator.py @@ -0,0 +1,30 @@ +"""Tests for endpoint_decorator.py.""" +# pylint: disable=too-few-public-methods +from __future__ import annotations + +import pytest + +from starlite_saqlalchemy import endpoint_decorator + + +def test_endpoint_decorator() -> None: + """Test for basic functionality.""" + + @endpoint_decorator.endpoint(base_url="/something") + class Endpoint: + """Endpoints for something.""" + + root = "" + nested = "/somewhere" + + assert Endpoint.root == "/something/" + assert Endpoint.nested == "/something/somewhere" + + +def test_endpoint_decorator_raises_if_no_base_url() -> None: + """Test raising behavior when no base_url provided.""" + with pytest.raises(RuntimeError): + + @endpoint_decorator.endpoint + class Endpoint: # pylint: disable=unused-variable + """whoops.""" diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py new file mode 100644 index 00000000..e067fc69 --- /dev/null +++ b/tests/unit/test_http.py @@ -0,0 +1,44 @@ +"""Tests for http.py.""" +# pylint: disable=protected-access +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from starlite_saqlalchemy import http + +if TYPE_CHECKING: + + from pytest import MonkeyPatch + + +async def test_client_request(monkeypatch: MonkeyPatch) -> None: + """Tests logic of request() method.""" + response_mock = MagicMock() + request_mock = AsyncMock(return_value=response_mock) + monkeypatch.setattr(http.Client._client, "request", request_mock) + res = await http.Client().request("with", "args", and_some="kwargs") + request_mock.assert_called_once_with("with", "args", and_some="kwargs") + response_mock.raise_for_status.assert_called_once() + assert res is response_mock + + +async def test_client_raises_client_exception(monkeypatch: MonkeyPatch) -> None: + """Tests that we convert httpx exceptions into ClientException.""" + exc = httpx.HTTPError("a message") + req = AsyncMock(side_effect=exc) + req.url = "http://whatever.com" + exc.request = req + monkeypatch.setattr(http.Client._client, "request", req) + with pytest.raises(http.ClientException): + await http.Client().request() + + +def test_client_json() -> None: + """Tests the json() and unwrap_json() passthrough.""" + resp = MagicMock() + resp.json.return_value = {"data": "data"} + assert http.Client().json(resp) == {"data": "data"} diff --git a/tests/unit/test_init_plugin.py b/tests/unit/test_init_plugin.py new file mode 100644 index 00000000..dfbda2aa --- /dev/null +++ b/tests/unit/test_init_plugin.py @@ -0,0 +1,82 @@ +"""Tests for init_plugin.py.""" +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest +from starlite import Starlite +from starlite.cache import SimpleCacheBackend + +from starlite_saqlalchemy import init_plugin + +if TYPE_CHECKING: + from typing import Any + + from pytest import MonkeyPatch + + +def test_config_switches() -> None: + """Tests that the app produced with all config switches off is as we + expect.""" + config = init_plugin.PluginConfig( + do_after_exception=False, + do_cache=False, + do_compression=False, + # pyright reckons this parameter doesn't exist, I beg to differ + do_collection_dependencies=False, # pyright:ignore + do_exception_handlers=False, + do_health_check=False, + do_logging=False, + do_openapi=False, + do_response_class=False, + do_sentry=False, + do_sqlalchemy_plugin=False, + do_worker=False, + ) + app = Starlite( + route_handlers=[], + openapi_config=None, + on_app_init=[init_plugin.ConfigureApp(config=config)], + ) + assert app.compression_config is None + assert app.logging_config is None + assert app.openapi_config is None + assert app.response_class is None + assert isinstance(app.cache.backend, SimpleCacheBackend) + # client.close and redis.close go in there unconditionally atm + assert len(app.on_shutdown) == 2 + assert not app.after_exception + assert not app.dependencies + assert not app.exception_handlers + assert not app.on_startup + assert not app.plugins + assert not app.routes + + +def test_do_worker_but_not_logging(monkeypatch: MonkeyPatch) -> None: + """Tests branch where we can have the worker enabled, but logging + disabled.""" + mock = MagicMock() + monkeypatch.setattr(init_plugin, "create_worker_instance", mock) + config = init_plugin.PluginConfig(do_logging=False) + Starlite(route_handlers=[], on_app_init=[init_plugin.ConfigureApp(config=config)]) + mock.assert_called_once() + call = mock.mock_calls[0] + assert "before_process" not in call.kwargs + assert "after_process" not in call.kwargs + + +@pytest.mark.parametrize( + ("in_", "out"), + [ + (["something"], ["something"]), + ("something", ["something"]), + ([], []), + (None, []), + ], +) +def test_ensure_list(in_: Any, out: Any) -> None: + """Test _ensure_list() functionality.""" + # pylint: disable=protected-access + assert init_plugin.ConfigureApp._ensure_list(in_) == out diff --git a/tests/unit/test_orm.py b/tests/unit/test_orm.py index 7b1e17e8..daf57601 100644 --- a/tests/unit/test_orm.py +++ b/tests/unit/test_orm.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from starlite_saqlalchemy import orm +from tests.utils.domain import Author, CreateDTO def test_sqla_touch_updated_timestamp() -> None: @@ -12,3 +13,11 @@ def test_sqla_touch_updated_timestamp() -> None: orm.touch_updated_timestamp(mock_session) for mock_instance in mock_session.dirty: assert isinstance(mock_instance.updated, datetime.datetime) + + +def test_from_dto() -> None: + """Test conversion of a DTO instance to a model instance.""" + data = CreateDTO(name="someone", dob="1982-03-22") + author = Author.from_dto(data) + assert author.name == "someone" + assert author.dob == datetime.date(1982, 3, 22)