From aa9b1c3a83daee390ca1b2bac3ef204c26b6ab56 Mon Sep 17 00:00:00 2001 From: Daniel Tiesling Date: Wed, 15 Nov 2023 23:52:42 -0800 Subject: [PATCH] Adds more tests --- flask_muck/__init__.py | 2 + flask_muck/exceptions.py | 4 - flask_muck/views.py | 95 ++++++++++++++---------- poetry.lock | 66 ++++++++++++++++- pyproject.toml | 1 + tests/app.py | 6 +- tests/conftest.py | 6 +- tests/test.py | 155 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 285 insertions(+), 50 deletions(-) diff --git a/flask_muck/__init__.py b/flask_muck/__init__.py index e69de29..1502650 100644 --- a/flask_muck/__init__.py +++ b/flask_muck/__init__.py @@ -0,0 +1,2 @@ +from .views import MuckApiView +from .callback import MuckCallback diff --git a/flask_muck/exceptions.py b/flask_muck/exceptions.py index 662741f..4525a6d 100644 --- a/flask_muck/exceptions.py +++ b/flask_muck/exceptions.py @@ -1,6 +1,2 @@ -class MuckApiValidationException(Exception): - pass - - class MuckImplementationError(Exception): pass diff --git a/flask_muck/views.py b/flask_muck/views.py index 30f7028..76f5f24 100644 --- a/flask_muck/views.py +++ b/flask_muck/views.py @@ -6,6 +6,7 @@ from typing import Optional, Union, Any from flask import request, Blueprint +from flask.typing import ResponseReturnValue from flask.views import MethodView from marshmallow import Schema from sqlalchemy.exc import IntegrityError @@ -19,10 +20,10 @@ ) from webargs import fields from webargs.flaskparser import parser +from werkzeug.exceptions import MethodNotAllowed, BadRequest, Conflict from flask_muck.callback import CallbackType from flask_muck.callback import MuckCallback -from flask_muck.exceptions import MuckApiValidationException from flask_muck.types import SqlaModelType, JsonDict, ResourceId, SqlaModel from flask_muck.utils import ( get_url_rule, @@ -68,12 +69,18 @@ class MuckApiView(MethodView): allowed_methods: set[str] = {"GET", "POST", "PUT", "PATCH", "DELETE"} primary_key_column: str = "id" primary_key_type: Union[type[int], type[str]] = int - filter_operator_separator: str = "__" + operator_separator: str = "__" @property def query(self) -> Query: return self.session.query(self.Model) + def dispatch_request(self, **kwargs: Any) -> ResponseReturnValue: + """Overriden to check the list of allowed_methods.""" + if request.method.lower() not in [m.lower() for m in self.allowed_methods]: + raise MethodNotAllowed + return super().dispatch_request(**kwargs) + def _execute_callbacks( self, resource: SqlaModel, @@ -111,7 +118,7 @@ def _get_clean_filter_data(self, filters: Optional[str]) -> JsonDict: try: return json.loads(filters) except JSONDecodeError: - raise MuckApiValidationException(f"Filters [{filters}] is not valid json.") + raise BadRequest(f"Filters [{filters}] is not valid json.") def _get_kwargs_from_request_payload(self) -> JsonDict: """Creates the correct schema based on request method and returns a sanitized dictionary of kwargs from the @@ -225,7 +232,7 @@ def post(self) -> tuple[JsonDict, int]: resource = self._create_resource(kwargs) except IntegrityError as e: self.session.rollback() - raise MuckApiValidationException(str(e)) + raise Conflict(str(e)) self._execute_callbacks(resource, kwargs, CallbackType.pre) self.session.commit() self._execute_callbacks(resource, kwargs, CallbackType.post) @@ -275,48 +282,49 @@ def _get_query_filters( for column_name, value in filters.items(): # Get operator. operator = None - if self.filter_operator_separator in column_name: - column_name, operator = column_name.split( - self.filter_operator_separator - ) + if self.operator_separator in column_name: + column_name, operator = column_name.split(self.operator_separator) # Handle nested filters. if "." in column_name: relationship_name, column_name = column_name.split(".") - field = getattr(self.Model, relationship_name) + field = getattr(self.Model, relationship_name, None) if not field: - continue + raise BadRequest( + f"{column_name} is not a valid filter field. The relationship does not exist." + ) SqlaModel = field.property.mapper.class_ join_models.add(SqlaModel) else: SqlaModel = self.Model - if hasattr(SqlaModel, column_name): - column = getattr(SqlaModel, column_name) - if operator == "gt": - filter = column > value - elif operator == "gte": - filter = column >= value - elif operator == "lt": - filter = column < value - elif operator == "lte": - filter = column <= value - elif operator == "ne": - filter = column != value - elif operator == "in": - filter = column.in_(value) - elif operator == "not_in": - filter = column.not_in(value) - else: - filter = column == value - query_filters.append(filter) + if not (column := getattr(SqlaModel, column_name, None)): + raise BadRequest(f"{column_name} is not a valid filter field.") + + if operator == "gt": + filter = column > value + elif operator == "gte": + filter = column >= value + elif operator == "lt": + filter = column < value + elif operator == "lte": + filter = column <= value + elif operator == "ne": + filter = column != value + elif operator == "in": + filter = column.in_(value) + elif operator == "not_in": + filter = column.not_in(value) + else: + filter = column == value + query_filters.append(filter) return query_filters, join_models def _get_query_order_by( self, sort: str ) -> tuple[Optional[UnaryExpression], set[SqlaModelType]]: - if "__" in sort: - column_name, direction = sort.split("__") + if self.operator_separator in sort: + column_name, direction = sort.split(self.operator_separator) else: column_name, direction = sort, "asc" @@ -340,7 +348,7 @@ def _get_query_order_by( elif direction == "desc": order_by = column.desc() else: - raise MuckApiValidationException( + raise BadRequest( f"Invalid sort direction: {direction}. Must asc or desc" ) return order_by, join_models @@ -368,24 +376,31 @@ def add_crud_to_blueprint(cls, blueprint: Blueprint) -> None: """Adds CRUD endpoints to a blueprint.""" url_rule = get_url_rule(cls, None) api_view = cls.as_view(f"{cls.api_name}_api") + + # In the special case that this API represents a ONE-TO-ONE relationship, use / for all methods. if cls.one_to_one_api: blueprint.add_url_rule( url_rule, defaults={"resource_id": None}, view_func=api_view, - methods=cls.allowed_methods, + methods={"GET", "PUT", "PATCH", "DELETE"}, ) - if "POST" in cls.allowed_methods: + + else: + # Create endpoint - POST on / blueprint.add_url_rule(url_rule, view_func=api_view, methods=["POST"]) - if "GET" in cls.allowed_methods: + + # List endpoint - GET on / blueprint.add_url_rule( url_rule, defaults={"resource_id": None}, view_func=api_view, methods=["GET"], ) - blueprint.add_url_rule( - f"{url_rule}/", - view_func=api_view, - methods=cls.allowed_methods.intersection({"GET", "PUT", "PATCH", "DELETE"}), - ) + + # Detail, Update, Patch, Delete endpoints - GET, PUT, PATCH, DELETE on / + blueprint.add_url_rule( + f"{url_rule}/", + view_func=api_view, + methods={"GET", "PUT", "PATCH", "DELETE"}, + ) diff --git a/poetry.lock b/poetry.lock index 8951ec0..c094fdc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -36,6 +36,70 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.3.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, + {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, + {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, + {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, + {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, + {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, + {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, + {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, + {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, + {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, + {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, + {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, + {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, + {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "exceptiongroup" version = "1.1.3" @@ -700,4 +764,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "27c234c52e6e13f0a8bd5c5892734e4714e9b306846a5711bfde6f44b15daff0" +content-hash = "33cc33a90ce873d3007026e56e5ef5ac58d9b4ddcd74c0bf1ac8f0192f5a0c83" diff --git a/pyproject.toml b/pyproject.toml index c1ebb30..fdf9af5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ sqlalchemy-stubs = "^0.4" pytest = "^7.4.3" flask-login = "^0.6.3" flask-sqlalchemy = "^3.1.1" +coverage = "^7.3.2" [tool.mypy] packages = "flask_muck" diff --git a/tests/app.py b/tests/app.py index 5f0203f..6b17268 100644 --- a/tests/app.py +++ b/tests/app.py @@ -10,7 +10,7 @@ ) from flask_sqlalchemy import SQLAlchemy from marshmallow import fields as mf -from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, Mapped from flask_muck.views import MuckApiView @@ -37,6 +37,7 @@ class GuardianModel(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) name = db.Column(db.String, nullable=False) age = db.Column(db.Integer, nullable=True) + children: Mapped[list["ChildModel"]] = db.relationship() class ChildModel(db.Model): @@ -44,12 +45,15 @@ class ChildModel(db.Model): name = db.Column(db.String, nullable=False) age = db.Column(db.Integer, nullable=True) guardian_id = db.Column(db.Integer, db.ForeignKey(GuardianModel.id)) + guardian = db.relationship(GuardianModel, back_populates="children") + toys: Mapped[list["ToyModel"]] = db.relationship() class ToyModel(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) name = db.Column(db.String, nullable=False) child_id = db.Column(db.Integer, db.ForeignKey(ChildModel.id)) + child = db.relationship(ChildModel, back_populates="toys") class GuardianSchema(ma.Schema): diff --git a/tests/conftest.py b/tests/conftest.py index c089ce8..53fd602 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -115,12 +115,16 @@ def simpson_family(db): @pytest.fixture def belcher_family(db): bob = GuardianModel(name="Bob", age=46) + db.session.add(bob) + db.session.flush() tina = ChildModel(name="Tina", age=12, guardian_id=bob.id) louise = ChildModel(name="Louise", age=9, guardian_id=bob.id) gene = ChildModel(name="Gene", age=11, guardian_id=bob.id) + db.session.add_all([tina, louise, gene]) + db.session.flush() pony = ToyModel(name="Pony", child_id=tina.id) hat = ToyModel(name="Hat", child_id=louise.id) keyboard = ToyModel(name="Keyboard", child_id=gene.id) - db.session.add_all([bob, tina, louise, gene, pony, hat, keyboard]) + db.session.add_all([pony, hat, keyboard]) db.session.flush() return bob, tina, louise, gene, pony, hat, keyboard diff --git a/tests/test.py b/tests/test.py index 9e4e503..58e8981 100644 --- a/tests/test.py +++ b/tests/test.py @@ -8,7 +8,13 @@ get_query_filters_from_request_path, get_join_models_from_parent_views, ) -from tests.app import GuardianModel, ToyApiView, ChildModel, ToyModel +from tests.app import ( + GuardianModel, + ToyApiView, + ChildModel, + ToyModel, + BaseApiView, +) class TestBasicCrud: @@ -34,19 +40,162 @@ def test_delete(self, client, guardian): assert GuardianModel.query.count() == 0 +class TestAllowedMethods: + def test_get_only(self, client, monkeypatch): + monkeypatch.setattr(BaseApiView, "allowed_methods", {"GET"}) + assert client.get("/api/v1/guardians/").status_code == 200 + assert client.post("/api/v1/guardians/").status_code == 405 + assert client.put("/api/v1/guardians/").status_code == 405 + assert client.patch("/api/v1/guardians/").status_code == 405 + assert client.delete("/api/v1/guardians/").status_code == 405 + + def test_no_methods(self, client, monkeypatch): + monkeypatch.setattr(BaseApiView, "allowed_methods", {}) + assert client.get("/api/v1/guardians/").status_code == 405 + assert client.post("/api/v1/guardians/").status_code == 405 + assert client.put("/api/v1/guardians/").status_code == 405 + assert client.patch("/api/v1/guardians/").status_code == 405 + assert client.delete("/api/v1/guardians/").status_code == 405 + + @pytest.mark.usefixtures("simpson_family", "belcher_family") class TestFiltering: @pytest.fixture def filter_guardians(self, get): - def _filter_guardians(filters: dict): + def _filter_guardians(filters: dict, expected_status_code: int = 200): return get( - f"/api/v1/guardians/", query_string={"filters": json.dumps(filters)} + f"/api/v1/guardians/", + query_string={"filters": json.dumps(filters)}, + expected_status_code=expected_status_code, ) return _filter_guardians def test_equal(self, filter_guardians): assert filter_guardians({"name": "Marge"}) == [{"name": "Marge"}] + assert filter_guardians({"name": "Bob"}) == [{"name": "Bob"}] + assert filter_guardians({"name": "Marge", "age": 34}) == [{"name": "Marge"}] + assert filter_guardians({"name": "Marge", "age": 45}) == [] + + def test_gt(self, filter_guardians): + assert filter_guardians({"age__gt": 18}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + assert filter_guardians({"age__gt": 34}) == [{"name": "Bob"}] + assert filter_guardians({"age__gt": 46}) == [] + + def test_gte(self, filter_guardians): + assert filter_guardians({"age__gte": 18}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + assert filter_guardians({"age__gte": 34}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + assert filter_guardians({"age__gte": 46}) == [{"name": "Bob"}] + assert filter_guardians({"age__gte": 47}) == [] + + def test_lt(self, filter_guardians): + assert filter_guardians({"age__lt": 18}) == [] + assert filter_guardians({"age__lt": 34}) == [] + assert filter_guardians({"age__lt": 46}) == [{"name": "Marge"}] + assert filter_guardians({"age__lt": 47}) == [{"name": "Marge"}, {"name": "Bob"}] + + def test_lte(self, filter_guardians): + assert filter_guardians({"age__lte": 18}) == [] + assert filter_guardians({"age__lte": 34}) == [{"name": "Marge"}] + assert filter_guardians({"age__lte": 46}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + assert filter_guardians({"age__lte": 47}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + + def test_in(self, filter_guardians): + assert filter_guardians({"name__in": ["Marge", "Bob"]}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + assert filter_guardians({"name__in": ["Marge"]}) == [{"name": "Marge"}] + assert filter_guardians({"name__in": ["Bob"]}) == [{"name": "Bob"}] + assert filter_guardians({"name__in": ["Billy"]}) == [] + + def test_not_in(self, filter_guardians): + assert filter_guardians({"name__not_in": ["Marge", "Bob"]}) == [] + assert filter_guardians({"name__not_in": ["Marge"]}) == [{"name": "Bob"}] + assert filter_guardians({"name__not_in": ["Bob"]}) == [{"name": "Marge"}] + assert filter_guardians({"name__not_in": ["Billy"]}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + + def test_ne(self, filter_guardians): + assert filter_guardians({"name__ne": "Marge"}) == [{"name": "Bob"}] + assert filter_guardians({"name__ne": "Bob"}) == [{"name": "Marge"}] + assert filter_guardians({"name__ne": "Billy"}) == [ + {"name": "Marge"}, + {"name": "Bob"}, + ] + + def test_change_operator_separator(self, filter_guardians, monkeypatch): + monkeypatch.setattr(BaseApiView, "operator_separator", "|") + assert filter_guardians({"name|ne": "Marge"}) == [{"name": "Bob"}] + assert filter_guardians({"name|in": ["Marge"]}) == [{"name": "Marge"}] + + def test_nested_filter(self, filter_guardians, client): + assert filter_guardians({"children.name": "Bart"}) == [{"name": "Marge"}] + assert filter_guardians({"children.name": "Gene"}) == [{"name": "Bob"}] + filter_guardians({"children.nope": "fail"}, expected_status_code=400) + + +class TestSort: + def test_sort(self, get, simpson_family, belcher_family): + marge, bart, maggie, lisa, skateboard, saxophone, pacifier = simpson_family + assert get( + f"/api/v1/guardians/{marge.id}/children/", query_string={"sort": "name"} + ) == [{"name": bart.name}, {"name": lisa.name}, {"name": maggie.name}] + assert get( + f"/api/v1/guardians/{marge.id}/children/", query_string={"sort": "age"} + ) == [{"name": maggie.name}, {"name": lisa.name}, {"name": bart.name}] + + def test_sort_asc(self, get, simpson_family, belcher_family): + marge, bart, maggie, lisa, skateboard, saxophone, pacifier = simpson_family + assert get( + f"/api/v1/guardians/{marge.id}/children/", query_string={"sort": "age__asc"} + ) == [{"name": maggie.name}, {"name": lisa.name}, {"name": bart.name}] + assert get( + f"/api/v1/guardians/{marge.id}/children/", + query_string={"sort": "name__asc"}, + ) == [{"name": bart.name}, {"name": lisa.name}, {"name": maggie.name}] + + def test_sort_desc(self, get, simpson_family, belcher_family): + marge, bart, maggie, lisa, skateboard, saxophone, pacifier = simpson_family + assert get( + f"/api/v1/guardians/{marge.id}/children/", + query_string={"sort": "age__desc"}, + ) == [{"name": bart.name}, {"name": lisa.name}, {"name": maggie.name}] + assert get( + f"/api/v1/guardians/{marge.id}/children/", + query_string={"sort": "name__desc"}, + ) == [{"name": maggie.name}, {"name": lisa.name}, {"name": bart.name}] + + def test_change_operator_separator( + self, get, simpson_family, belcher_family, monkeypatch + ): + monkeypatch.setattr(BaseApiView, "operator_separator", "|") + marge, bart, maggie, lisa, skateboard, saxophone, pacifier = simpson_family + assert get( + f"/api/v1/guardians/{marge.id}/children/", + query_string={"sort": "age|desc"}, + ) == [{"name": bart.name}, {"name": lisa.name}, {"name": maggie.name}] + assert get( + f"/api/v1/guardians/{marge.id}/children/", + query_string={"sort": "name|desc"}, + ) == [{"name": maggie.name}, {"name": lisa.name}, {"name": bart.name}] class TestNestedApis: