From d5e947753538f0f2f1e154ed1889ec85bcd71b05 Mon Sep 17 00:00:00 2001 From: mishaschwartz <4380924+mishaschwartz@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:11:34 -0400 Subject: [PATCH 1/2] user surveys models --- marble_api/utils/models.py | 87 +++++++++++- marble_api/versions/v1/data_request/models.py | 37 ++---- .../versions/v1/user_survey/__init__.py | 0 marble_api/versions/v1/user_survey/models.py | 124 ++++++++++++++++++ 4 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 marble_api/versions/v1/user_survey/__init__.py create mode 100644 marble_api/versions/v1/user_survey/models.py diff --git a/marble_api/utils/models.py b/marble_api/utils/models.py index 60594d3..0fd1c41 100644 --- a/marble_api/utils/models.py +++ b/marble_api/utils/models.py @@ -1,9 +1,22 @@ from copy import deepcopy -from typing import Any +from typing import Annotated, Any import bson -from pydantic import BaseModel, create_model +from pydantic import ( + AwareDatetime, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + FieldSerializationInfo, + computed_field, + create_model, + field_serializer, +) from pydantic.fields import FieldInfo +from pydantic.json_schema import SkipJsonSchema + +PyObjectId = Annotated[str, BeforeValidator(str)] def partial_model(model: type[BaseModel]) -> type[BaseModel]: @@ -56,3 +69,73 @@ def object_id(id_: str, error: Exception | None) -> bson.ObjectId: except bson.errors.InvalidId as err: if error is not None: raise error from err + + +class MarbleBaseModel(BaseModel): + """ + Base model for all database models used in this repo. + + Includes: + - an id stored as a PyObjectId in the database + - created field calculated from the id value + - updated field that should be updated by the route + """ + + id: SkipJsonSchema[PyObjectId | None] = Field(default=None, validation_alias="_id", exclude=True) + updated: SkipJsonSchema[AwareDatetime | None] = None # updated should set by the route + model_config = ConfigDict(validate_by_name=True, arbitrary_types_allowed=True) + + +class MarbleBaseModelUpdate(MarbleBaseModel): + """ + Base model for update models in this repo. + + Sets the model_config without validate_by_name=True to ensure that the id cannot easily be overwritten. + + NOTE: the child class that inherits from this should also be decorated with the partial_model function + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, json_encoders={bson.ObjectId: str}) + + +class MarbleBaseModelPublic(MarbleBaseModel): + """ + Base model for public models (visible to the end user) in this repo. + + Makes the id and updated fields visible and creates a computed field for model creation time. + """ + + id: Annotated[str, BeforeValidator(str)] = Field(..., validation_alias="_id") + updated: AwareDatetime + model_config = ConfigDict(validate_by_name=True, arbitrary_types_allowed=True, extra="allow") + + @computed_field + def created(self) -> AwareDatetime: + """Set the created time based on the object id.""" + return object_id(self.id, None).generation_time + + +class MarbleUserModel(MarbleBaseModel): + """ + Base model for models that are associated with a user. + + Requires that the user attribute is set when serialized. + """ + + user: SkipJsonSchema[str | None] = None # user is set by the route after the model is first validated + + @field_serializer("user") + def require_user_set(self, value: str, info: FieldSerializationInfo) -> str: + """Require that the user name be set when the model is serialized.""" + assert value, f"{info.field_name} must be set and non-empty" + return value + + +class MarbleUserModelUpdate(MarbleBaseModelUpdate, MarbleUserModel): + """Base model for updating user associated models.""" + + +class MarbleUserModelPublic(MarbleBaseModelPublic, MarbleUserModel): + """Base model for public models associated with a user.""" + + user: str # user is required to be set in the database diff --git a/marble_api/versions/v1/data_request/models.py b/marble_api/versions/v1/data_request/models.py index 45b0547..32a2e50 100644 --- a/marble_api/versions/v1/data_request/models.py +++ b/marble_api/versions/v1/data_request/models.py @@ -3,7 +3,6 @@ from datetime import timezone from typing import Required, Self, TypedDict -from bson import ObjectId from pydantic import ( AfterValidator, AwareDatetime, @@ -13,12 +12,10 @@ Field, FieldSerializationInfo, ValidationInfo, - computed_field, field_serializer, field_validator, model_validator, ) -from pydantic.functional_validators import BeforeValidator from pydantic.json_schema import SkipJsonSchema from stac_pydantic.item import Item from stac_pydantic.links import Links @@ -31,9 +28,13 @@ collapse_geometries, validate_collapsible, ) -from marble_api.utils.models import object_id, partial_model +from marble_api.utils.models import ( + MarbleUserModel, + MarbleUserModelPublic, + MarbleUserModelUpdate, + partial_model, +) -PyObjectId = Annotated[str, BeforeValidator(str)] Temporal = Annotated[list[AwareDatetime], Field(..., min_length=1, max_length=2), AfterValidator(sorted)] @@ -45,16 +46,13 @@ class Author(TypedDict, total=False): email: EmailStr | None = None -class DataRequest(BaseModel): +class DataRequest(MarbleUserModel): """ Database model for Data Requests. This object contains the representation of the data in the database. """ - id: SkipJsonSchema[PyObjectId | None] = Field(default=None, validation_alias="_id", exclude=True) - user: SkipJsonSchema[str | None] = None # user is set by the route after the model is first validated - updated: SkipJsonSchema[AwareDatetime | None] = None # updated is set by the route title: str description: str | None = None authors: list[Author] @@ -65,7 +63,6 @@ class DataRequest(BaseModel): assets: dict[str, Asset] contact: EmailStr extra_properties: dict[str, str] = {} - model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) @field_validator("title", "description", "authors", "assets", "contact") @classmethod @@ -97,15 +94,9 @@ def convert_from_utc(self, value: Temporal, info: FieldSerializationInfo) -> lis for i, t in enumerate(value) ] - @field_serializer("user") - def require_user_set(self, value: str, info: FieldSerializationInfo) -> str: - """Require that the user name be set when the model is serialized.""" - assert value, f"{info.field_name} must be set and non-empty" - return value - @partial_model -class DataRequestUpdate(DataRequest): +class DataRequestUpdate(MarbleUserModelUpdate, DataRequest): """ Update model for Data Requests. @@ -113,26 +104,16 @@ class DataRequestUpdate(DataRequest): Fields should be optional unless they *must* be updated every time a change is made. """ - model_config = ConfigDict(arbitrary_types_allowed=True, json_encoders={ObjectId: str}) - -class DataRequestPublic(DataRequest): +class DataRequestPublic(MarbleUserModelPublic, DataRequest): """ Public model for Data Requests. This allows for the id field to be included in the response extra fields (like stac_item) so that they can be visible in API responses. """ - id: Annotated[str, BeforeValidator(str)] = Field(..., validation_alias="_id") - user: str # user is required to be set in the database - updated: AwareDatetime model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") - @computed_field - def created(self) -> AwareDatetime: - """Set the created time based on the object id.""" - return object_id(self.id, None).generation_time - @property def stac_item(self) -> Item: """Dynamically create a STAC item representation of this data.""" diff --git a/marble_api/versions/v1/user_survey/__init__.py b/marble_api/versions/v1/user_survey/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marble_api/versions/v1/user_survey/models.py b/marble_api/versions/v1/user_survey/models.py new file mode 100644 index 0000000..f8f2e58 --- /dev/null +++ b/marble_api/versions/v1/user_survey/models.py @@ -0,0 +1,124 @@ +import re +from abc import ABC, abstractmethod +from typing import Any, Literal, Self + +from pydantic import BaseModel, Field, PositiveInt, model_validator + +from marble_api.utils.models import ( + MarbleBaseModel, + MarbleBaseModelPublic, + MarbleBaseModelUpdate, + MarbleUserModel, + MarbleUserModelPublic, + MarbleUserModelUpdate, + partial_model, +) + + +class Question(BaseModel, ABC): + """ + Base class for survey questions. + + Question text is the text of the question itself. + The required field indicates if a SurveyResponse must contain an answer to this question. + """ + + text: str = Field(..., min_length=1) + required: bool = False + + @abstractmethod + def validate_response(self, response: Any) -> None: # noqa: ANN401 + """Validate the response is valid for this question.""" + raise NotImplementedError + + +class TextQuestion(Question): + """ + Question with a text (string) response. + + Specify minimum and maximum character length of the response. + Optionally specify a regular expression pattern that the answer must match. + """ + + question_type: Literal["text"] = "text" + min_length: int = Field(0, ge=0) + max_length: int = Field(1000, le=1000) # 1000 char limit to prevent abuse + pattern: re.Pattern | None = None + + @model_validator(mode="after") + def validate_length(self) -> Self: + """Check that length values make sense.""" + assert self.min_length <= self.max_length, "max_length must be >= min_length" + return self + + def validate_response(self, response: str) -> None: + """Validate that a response is valid for this question.""" + assert isinstance(response, str), "Response '{r}'" + + +class ChoiceQuestion(Question): + """ + Question with a multiple choice response. + + Specify minimum and maximum number of choices that a user can choose in a given answer. + + Specify the choices themselves as a dictionary where keys are unique choice IDs and values are the + choice text to present to the user. + + The allow_other boolean indicates that users are allowed to specify another option (other than those + specified in the choices dictionary). + + If the allow_other boolean is True, specify an optional regular expression pattern that the other option + must match. + """ + + question_type: Literal["choice"] = "choice" + min_choices: int = Field(1, ge=0) + max_choices: PositiveInt | None = 1 + choices: dict[str, str] = Field(..., min_length=1) + allow_other: bool = False + pattern: re.Pattern | None = None + + @model_validator(mode="after") + def validate_min_max_choices(self) -> Self: + """Check that length values make sense.""" + if self.max_choices is not None: + assert self.min_choices <= self.max_choices, "max_choices must be None or >= min_choices" + return self + + @model_validator(mode="after") + def validate_min_choices(self) -> Self: + """Check that length values make sense.""" + assert self.min_choices <= len(self.choices), f"specify at least min_choices ({self.min_choices}) choices" + return self + + +class Survey(MarbleBaseModel): + """Survey containing questions.""" + + survey: list[TextQuestion | ChoiceQuestion] + + +@partial_model +class SurveyUpdate(MarbleBaseModelUpdate, Survey): + """Model to update a survey.""" + + # TODO: ensure that can't be updated if there are associated responses. + # TODO: allow updating/viewing a survey by the index of the question + + +class SurveyPublic(MarbleBaseModelPublic, Survey): + """Model that shows publicly visible survey.""" + + +class SurveyResponse(MarbleUserModel): + """Response to a survey question.""" + + +@partial_model +class SurveyResponseUpdate(MarbleUserModelUpdate, SurveyResponse): + """Model to update a response to a survey question.""" + + +class SurveyResponsePublic(MarbleUserModelPublic, SurveyResponse): + """Model that shows publicly visible response to a survey question.""" From 450826204f64b644906fe309a44986d70c8289a0 Mon Sep 17 00:00:00 2001 From: mishaschwartz <4380924+mishaschwartz@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:14:34 -0400 Subject: [PATCH 2/2] user survey routes and test refactor --- marble_api/app.py | 1 + marble_api/database/__init__.py | 18 + marble_api/utils/models.py | 100 +-- marble_api/utils/routes.py | 145 +++- marble_api/versions/v1/__init__.py | 5 + marble_api/versions/v1/data_request/models.py | 12 +- marble_api/versions/v1/data_request/routes.py | 122 +--- .../v1/{user_survey => survey}/__init__.py | 0 marble_api/versions/v1/survey/models.py | 239 +++++++ marble_api/versions/v1/survey/routes.py | 269 +++++++ marble_api/versions/v1/user_survey/models.py | 124 ---- pyproject.toml | 5 +- test/conftest.py | 9 +- test/faker_providers.py | 196 ++++- test/integration/conftest.py | 6 +- test/integration/utils/_test_auth.py | 63 ++ test/integration/utils/_test_routes.py | 242 +++++++ .../versions/v1/data_request/test_routes.py | 367 ++-------- .../versions/v1/survey/conftest.py | 9 + .../versions/v1/survey/test_routes.py | 675 ++++++++++++++++++ test/unit/database/test_database.py | 20 +- ...{test_utils_geojson.py => test_geojson.py} | 0 test/unit/utils/test_models.py | 181 +++++ test/unit/utils/test_routes.py | 264 +++++++ test/unit/utils/test_utils_models.py | 61 -- .../versions/v1/data_request/test_models.py | 45 +- test/unit/versions/v1/user_survey/conftest.py | 9 + .../versions/v1/user_survey/test_models.py | 307 ++++++++ 28 files changed, 2824 insertions(+), 670 deletions(-) rename marble_api/versions/v1/{user_survey => survey}/__init__.py (100%) create mode 100644 marble_api/versions/v1/survey/models.py create mode 100644 marble_api/versions/v1/survey/routes.py delete mode 100644 marble_api/versions/v1/user_survey/models.py create mode 100644 test/integration/utils/_test_auth.py create mode 100644 test/integration/utils/_test_routes.py create mode 100644 test/integration/versions/v1/survey/conftest.py create mode 100644 test/integration/versions/v1/survey/test_routes.py rename test/unit/utils/{test_utils_geojson.py => test_geojson.py} (100%) create mode 100644 test/unit/utils/test_models.py create mode 100644 test/unit/utils/test_routes.py delete mode 100644 test/unit/utils/test_utils_models.py create mode 100644 test/unit/versions/v1/user_survey/conftest.py create mode 100644 test/unit/versions/v1/user_survey/test_models.py diff --git a/marble_api/app.py b/marble_api/app.py index b151e30..41a351a 100644 --- a/marble_api/app.py +++ b/marble_api/app.py @@ -6,6 +6,7 @@ _metadata = metadata.metadata("marble_api").json + app = FastAPI( title=_metadata["name"], version=_metadata["version"], diff --git a/marble_api/database/__init__.py b/marble_api/database/__init__.py index 9e65410..9c275da 100644 --- a/marble_api/database/__init__.py +++ b/marble_api/database/__init__.py @@ -1,4 +1,7 @@ +from collections.abc import Callable + from pymongo import AsyncMongoClient +from pymongo.asynchronous.collection import AsyncCollection from pymongo.asynchronous.database import AsyncDatabase from marble_api._config import config @@ -18,3 +21,18 @@ def db(self) -> AsyncDatabase: client = Client(str(config.mongodb_uri), tz_aware=True) + + +def collection(name: str) -> Callable: + """ + Return a function that returns the named mongodb collection. + + Using this ensures that a new collection object is created when needed instead + of creating a collection once when the module is loaded (which may drop connection + to the database if the mongodb instance goes offline temporarily.) + """ + + def _(name: str = name) -> AsyncCollection: + return client.db[name] + + return _ diff --git a/marble_api/utils/models.py b/marble_api/utils/models.py index 0fd1c41..d8b22a1 100644 --- a/marble_api/utils/models.py +++ b/marble_api/utils/models.py @@ -1,7 +1,9 @@ -from copy import deepcopy +import re +from contextlib import suppress from typing import Annotated, Any import bson +from bson.regex import Regex as BsonRegex from pydantic import ( AwareDatetime, BaseModel, @@ -9,53 +11,27 @@ ConfigDict, Field, FieldSerializationInfo, + PydanticUndefinedAnnotation, computed_field, - create_model, field_serializer, ) -from pydantic.fields import FieldInfo from pydantic.json_schema import SkipJsonSchema -PyObjectId = Annotated[str, BeforeValidator(str)] - -def partial_model(model: type[BaseModel]) -> type[BaseModel]: +def _convert_bson_regex(value: Any) -> str | re.Pattern: # noqa: ANN401 """ - Make all fields in a BaseModel class optional. - - This makes each field's default None but does not update the annotation or - validations so explicitly setting the value to None may still raise a - validation error. Also, if a field has validate_default=True this will - make validate_default=False for the partial model to ensure that the new - (None) default value is not validated. - - >>> class C(BaseModel): - a: int - >>> C(a=2).a - 2 - >>> C() # validation error since a must be an integer - >>> @partial_model - ... class B(C): ... - >>> B().a # is None - >>> B(a=5).a - 5 - >>> B(a=None) # validation error since a must be an integer - - Adapted from https://stackoverflow.com/a/76560886/5992438 + Convert bson.regex.Regex to a python re.Pattern. + + MongoDB will return regular expressions as bson.regex.Regex which + pydantic doensn't know how to convert to a re.Pattern without help. """ + if isinstance(value, BsonRegex): + return value.try_compile() + return value - def make_field_optional(field: FieldInfo) -> tuple[Any, FieldInfo]: - new_field = deepcopy(field) - new_field.validate_default = False - new_field.default = None - return new_field.annotation, new_field - return create_model( - model.__name__, - __base__=model, - __module__=model.__module__, - **{name: make_field_optional(info) for name, info in model.model_fields.items()}, - ) +type PyObjectId = Annotated[str, BeforeValidator(str)] +type Pattern = Annotated[re.Pattern, BeforeValidator(_convert_bson_regex)] def object_id(id_: str, error: Exception | None) -> bson.ObjectId: @@ -71,6 +47,27 @@ def object_id(id_: str, error: Exception | None) -> bson.ObjectId: raise error from err +class PartialModel(BaseModel): + """Superclass for partial models (all fields are optional).""" + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs) -> None: + """ + Make all models that this model a "partial model". + + Partial models make all fields optional. + + This sets all fields to have a default value of None but don't update the annotation + so that fields can't explicitly be set to None. + """ + super().__pydantic_init_subclass__(**kwargs) + for field in cls.model_fields.values(): + field.default = None + + with suppress(PydanticUndefinedAnnotation): + cls.model_rebuild(force=True) + + class MarbleBaseModel(BaseModel): """ Base model for all database models used in this repo. @@ -83,26 +80,25 @@ class MarbleBaseModel(BaseModel): id: SkipJsonSchema[PyObjectId | None] = Field(default=None, validation_alias="_id", exclude=True) updated: SkipJsonSchema[AwareDatetime | None] = None # updated should set by the route - model_config = ConfigDict(validate_by_name=True, arbitrary_types_allowed=True) + model_config = ConfigDict(validate_by_name=True, arbitrary_types_allowed=True, validate_assignment=True) -class MarbleBaseModelUpdate(MarbleBaseModel): +class MarbleBaseModelUpdate(PartialModel, MarbleBaseModel): """ Base model for update models in this repo. - Sets the model_config without validate_by_name=True to ensure that the id cannot easily be overwritten. - - NOTE: the child class that inherits from this should also be decorated with the partial_model function + Ensures that subclasses of this model are partial models (all fields are optional) """ - model_config = ConfigDict(arbitrary_types_allowed=True, json_encoders={bson.ObjectId: str}) - class MarbleBaseModelPublic(MarbleBaseModel): """ Base model for public models (visible to the end user) in this repo. Makes the id and updated fields visible and creates a computed field for model creation time. + + Sets extra=allow so additional data can be added to models returned by route methods. + Does not set validate_assignment=True to allow flexibility when routes add/update data. """ id: Annotated[str, BeforeValidator(str)] = Field(..., validation_alias="_id") @@ -125,9 +121,10 @@ class MarbleUserModel(MarbleBaseModel): user: SkipJsonSchema[str | None] = None # user is set by the route after the model is first validated @field_serializer("user") - def require_user_set(self, value: str, info: FieldSerializationInfo) -> str: + def require_user_set(self, value: str, info: FieldSerializationInfo) -> SkipJsonSchema[str]: """Require that the user name be set when the model is serialized.""" - assert value, f"{info.field_name} must be set and non-empty" + if not value: + raise ValueError(f"{info.field_name} must be set and non-empty") return value @@ -138,4 +135,11 @@ class MarbleUserModelUpdate(MarbleBaseModelUpdate, MarbleUserModel): class MarbleUserModelPublic(MarbleBaseModelPublic, MarbleUserModel): """Base model for public models associated with a user.""" - user: str # user is required to be set in the database + user: str = Field(..., min_length=1) # user is required to be set in the database + + # NOTE: this must be redeclared so that the return annotation can be modified to + # allow the user field to show up in the JSON schema. + @field_serializer("user") + def require_user_set(self, value: str, info: FieldSerializationInfo) -> str: + """Require that the user name be set when the model is serialized.""" + return super().require_user_set(value, info) diff --git a/marble_api/utils/routes.py b/marble_api/utils/routes.py index 1e8c111..d1cd96a 100644 --- a/marble_api/utils/routes.py +++ b/marble_api/utils/routes.py @@ -1,13 +1,20 @@ -from typing import Any +import datetime +from typing import Annotated, Any, TypedDict import pymongo from bson import ObjectId -from fastapi import Request +from fastapi import HTTPException, Query, Request, Response, status +from pydantic import BaseModel from pymongo.asynchronous.collection import AsyncCollection from stac_pydantic.links import Links +from marble_api.database import client +from marble_api.utils.models import MarbleBaseModel -async def paginated_query( +type MongoRecord = dict[str, Any] + + +async def _paginated_query( collection: AsyncCollection, limit: int, request: Request, @@ -16,7 +23,7 @@ async def paginated_query( before: ObjectId | None = None, ascending: bool = True, **selector, -) -> tuple[list[dict[str, Any]], Links]: +) -> tuple[list[MongoRecord], Links]: """Return the result of a paginated query.""" reverse_it = False sort_order = pymongo.ASCENDING if ascending else pymongo.DESCENDING @@ -82,3 +89,133 @@ async def paginated_query( } ) return data, links + + +def _build_selector( + id_: ObjectId | None, user: str | None, additional_selector: dict[str, Any] | None +) -> dict[str, Any]: + selector = additional_selector or {} + if id_ is not None: + selector["_id"] = id_ + if user is not None: + selector["user"] = user + return selector + + +async def post_record(collection: AsyncCollection, user: str | None, model: MarbleBaseModel) -> MongoRecord: + """Create a record in the database collection based on the given model and return the created model.""" + if user is not None: + model.user = user + model.updated = datetime.datetime.now(tz=datetime.timezone.utc) + new_model = model.model_dump(by_alias=True) + result = await collection.insert_one(new_model) + new_model["id"] = str(result.inserted_id) + return new_model + + +async def patch_record( + collection: AsyncCollection, + id_: ObjectId | None, + user: str | None, + data: Any, # not MarbleBaseModelUpdate because patch can operate on individual fields # noqa: ANN401 + validation_class: MarbleBaseModel, + allow_update_user: bool = False, + additional_selector: dict[str, Any] | None = None, + set_prefix: str | None = None, +) -> MongoRecord: + """Update fields of a record in the database collection and return the updated record.""" + if isinstance(data, BaseModel): + updated_fields = data.model_dump(exclude_unset=True, by_alias=True) + else: + updated_fields = data # allows updating partial records that are not defined by a pydantic model + if set_prefix is not None: + updated_fields = {f"{set_prefix}{field}": value for field, value in updated_fields.items()} + selector = _build_selector(id_, user, additional_selector) + if not allow_update_user and "user" in updated_fields: + # do not allow updating user field + raise HTTPException(status_code=403, detail="Forbidden") + # updated timestamps are handled automatically + if updated_fields: + updated_fields["updated"] = datetime.datetime.now(tz=datetime.timezone.utc) + async with client.start_session() as session: + async with await session.start_transaction(): + result = await collection.find_one_and_update( + selector, {"$set": updated_fields}, return_document=pymongo.ReturnDocument.AFTER + ) + if result is not None: + validation_class(**result) + return result + else: + if (result := await collection.find_one(selector)) is not None: + return result + + raise HTTPException(status_code=404, detail="Not found") + + +async def get_record( + collection: AsyncCollection, + id_: ObjectId | None, + user: str | None, + additional_selector: dict[str, Any] | None = None, +) -> MongoRecord: + """Get a record from the database collection with the given id.""" + selector = _build_selector(id_, user, additional_selector) + if (result := await collection.find_one(selector)) is not None: + return result + + raise HTTPException(status_code=404, detail="Not found") + + +class RecordsResponse(TypedDict): + """Describes the return value for the get_records function.""" + + records: list[MongoRecord] + links: Links + + +async def get_records( + collection: AsyncCollection, + request: Request, + user: str | None = None, + after: ObjectId | None = None, + before: ObjectId | None = None, + limit: Annotated[int, Query(le=100, gt=0)] = 10, + sort_by: str = "id", + ascending: bool = True, + additional_selector: dict[str, Any] | None = None, + records_key: str = "records", +) -> RecordsResponse: + """Get records from the database collection, paginated according to the after, before, sort_by, and ascending params.""" + # note: created is not a field, it is based on the timesamp used to create the value of _id + if sort_by in ("id", "created"): + sort_by = "_id" + + selector = _build_selector(None, user, additional_selector) + + records, links = await _paginated_query( + collection=collection, + limit=limit, + request=request, + sort_by=sort_by, + after=after, + before=before, + ascending=ascending, + **selector, + ) + return {records_key: records, "links": links} + + +async def delete_record( + collection: AsyncCollection, + id_: ObjectId | None, + user: str | None, + additional_selector: dict[str, Any] | None = None, +) -> Response: + """Delete a data request from the database collection with the given id.""" + selector = _build_selector(id_, user, additional_selector) + + result = await collection.delete_one(selector) + if result.deleted_count == 1: + return Response(status_code=status.HTTP_204_NO_CONTENT) + + raise HTTPException(status_code=404, detail="Not found") diff --git a/marble_api/versions/v1/__init__.py b/marble_api/versions/v1/__init__.py index 8b822e2..a71539d 100644 --- a/marble_api/versions/v1/__init__.py +++ b/marble_api/versions/v1/__init__.py @@ -3,6 +3,7 @@ from marble_api.utils.auth import authenticate_magpie_admin, authenticate_magpie_user from marble_api.versions.v1.data_request.routes import admin_router as data_request_admin_router from marble_api.versions.v1.data_request.routes import user_router as data_request_user_router +from marble_api.versions.v1.survey.routes import survey_admin_router, survey_response_admin_router, survey_user_router router = APIRouter(prefix="/v1") @@ -12,5 +13,9 @@ user_router.include_router(data_request_user_router) admin_router.include_router(data_request_admin_router) +user_router.include_router(survey_user_router) +admin_router.include_router(survey_admin_router) +admin_router.include_router(survey_response_admin_router) + router.include_router(user_router) router.include_router(admin_router) diff --git a/marble_api/versions/v1/data_request/models.py b/marble_api/versions/v1/data_request/models.py index 32a2e50..0745bff 100644 --- a/marble_api/versions/v1/data_request/models.py +++ b/marble_api/versions/v1/data_request/models.py @@ -32,7 +32,6 @@ MarbleUserModel, MarbleUserModelPublic, MarbleUserModelUpdate, - partial_model, ) Temporal = Annotated[list[AwareDatetime], Field(..., min_length=1, max_length=2), AfterValidator(sorted)] @@ -68,8 +67,9 @@ class DataRequest(MarbleUserModel): @classmethod def min_length_if_set(cls, value: Sized | None, info: ValidationInfo) -> Sized | None: """Raise an error if the value is not None and is empty.""" - assert value is None or len(value), f"{info.field_name} must be None or non-empty" - return value + if value is None or len(value): + return value + raise ValueError(f"{info.field_name} must be None or non-empty") @field_validator("geometry") @classmethod @@ -83,7 +83,10 @@ def validate_geometries(cls, value: GeoJSON | None) -> dict | None: def get_tz_offset(self) -> Self: """Store the timezone offset for the temporal data.""" if self.temporal is not None: - self.tz_offset = [datetime.datetime.utcoffset(t).total_seconds() for t in self.temporal] + offsets = [datetime.datetime.utcoffset(t).total_seconds() for t in self.temporal] + if offsets != self.tz_offset: + # check first to avoid infinite recursion when validate_assignment=True + self.tz_offset = offsets return self @field_serializer("temporal") @@ -95,7 +98,6 @@ def convert_from_utc(self, value: Temporal, info: FieldSerializationInfo) -> lis ] -@partial_model class DataRequestUpdate(MarbleUserModelUpdate, DataRequest): """ Update model for Data Requests. diff --git a/marble_api/versions/v1/data_request/routes.py b/marble_api/versions/v1/data_request/routes.py index 12466a1..a34e69c 100644 --- a/marble_api/versions/v1/data_request/routes.py +++ b/marble_api/versions/v1/data_request/routes.py @@ -1,15 +1,11 @@ -import datetime -from collections.abc import AsyncGenerator from typing import Annotated, Literal from bson import ObjectId -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status -from pydantic_core import PydanticSerializationError -from pymongo import ReturnDocument +from fastapi import APIRouter, HTTPException, Query, Request, Response -from marble_api.database import client +from marble_api.database import collection from marble_api.utils.models import object_id -from marble_api.utils.routes import paginated_query +from marble_api.utils.routes import delete_record, get_record, get_records, patch_record, post_record from marble_api.versions.v1.data_request.models import ( DataRequest, DataRequestPublic, @@ -17,97 +13,58 @@ DataRequestUpdate, ) - -async def _handle_serialization_error() -> AsyncGenerator[None]: - try: - yield - except PydanticSerializationError as e: - raise HTTPException(status_code=422, detail=str(e)) from e - - user_router = APIRouter(prefix="/data-requests") -admin_router = APIRouter(prefix="/data-requests", dependencies=[Depends(_handle_serialization_error)]) +admin_router = APIRouter(prefix="/data-requests") def _data_request_id(id_: str) -> ObjectId: return object_id(id_, HTTPException(status_code=404, detail=f"data publish request with id={id_} not found")) +_collection = collection("data-request") + + @user_router.post("/") @admin_router.post("/") async def post_data_request_user(user: str, data_request: DataRequest) -> DataRequestPublic: """Create a new data request and return the newly created data request.""" - data_request.user = user - data_request.updated = datetime.datetime.now(tz=datetime.timezone.utc) - new_data_request = data_request.model_dump(by_alias=True) - result = await client.db["data-request"].insert_one(new_data_request) - new_data_request["id"] = str(result.inserted_id) - return new_data_request + return await post_record(_collection(), user, data_request) -def _check_user_change(data_request: DataRequestUpdate, user: str | None = None) -> None: - """Users cannot change the data request so that it belongs to a different user.""" - updated_fields = data_request.model_dump(exclude_unset=True, by_alias=True) - if updated_fields.get("user") and user != updated_fields.get("user"): - raise HTTPException(status_code=403, detail="Forbidden") - - -@user_router.patch("/{request_id}", dependencies=[Depends(_check_user_change)]) +@user_router.patch("/{request_id}") @admin_router.patch("/{request_id}") async def patch_data_request( request_id: str, data_request: DataRequestUpdate, user: str | None = None ) -> DataRequestPublic: """Update fields of data request and return the updated data request.""" - updated_fields = data_request.model_dump(exclude_unset=True, by_alias=True) - if user is not None: - data_request.user = user - selector = {"_id": _data_request_id(request_id)} - # updated timestamps are handled automatically - updated_fields["updated"] = datetime.datetime.now(tz=datetime.timezone.utc) - if updated_fields: - result = await client.db["data-request"].find_one_and_update( - selector, {"$set": updated_fields}, return_document=ReturnDocument.AFTER - ) - if result is not None: - return result - else: - if (result := await client.db["data-request"].find_one(selector)) is not None: - return result - - raise HTTPException(status_code=404, detail="data publish request not found") + return await patch_record( + _collection(), + _data_request_id(request_id), + user, + data_request, + DataRequestPublic, + allow_update_user=user is None, + ) @user_router.get("/{request_id}", response_model_by_alias=False) @admin_router.get("/{request_id}", response_model_by_alias=False) async def get_data_request(request_id: str, stac: bool = False, user: str | None = None) -> DataRequestPublic: """Get a data request with the given request_id.""" - selector = {"_id": _data_request_id(request_id)} - if user is not None: - selector["user"] = user - if (result := await client.db["data-request"].find_one(selector)) is not None: - if stac: - try: - result["stac_item"] = DataRequestPublic(**result).stac_item - except Exception as e: - raise Exception(result) from e - return result - - raise HTTPException(status_code=404, detail="data publish request not found") + result = await get_record(_collection(), _data_request_id(request_id), user) + if stac: + try: + result["stac_item"] = DataRequestPublic(**result).stac_item + except Exception as e: + raise Exception(result) from e + return result @user_router.delete("/{request_id}") @admin_router.delete("/{request_id}") -async def delete_data_request(request_id: str, request: Request, user: str | None = None) -> Response: +async def delete_data_request(request_id: str, user: str | None = None) -> Response: """Delete a data request with the given request_id.""" - selector = {"_id": _data_request_id(request_id)} - if user is not None: - selector["user"] = user - - result = await client.db["data-request"].delete_one(selector) - if result.deleted_count == 1: - return Response(status_code=status.HTTP_204_NO_CONTENT) - - raise HTTPException(status_code=404, detail="data publish request not found") + return await delete_record(_collection(), _data_request_id(request_id), user) @user_router.get("/") @@ -128,26 +85,19 @@ async def get_data_requests( This response is paginated and will only return at most limit objects at a time (maximum 100). Use the offset and limit parameters to select specific ranges of data requests. """ - # note: created is not a field, it is based on the timesamp used to create the value of _id - if sort_by in ("id", "created"): - sort_by = "_id" - - selector = {} - - if user is not None: - selector["user"] = user - - data_requests, links = await paginated_query( - collection=client.db["data-request"], - limit=limit, + response = await get_records( + collection=_collection(), request=request, - sort_by=sort_by, + user=user, after=_data_request_id(after) if after else None, before=_data_request_id(before) if before else None, + limit=limit, + sort_by=sort_by, ascending=ascending, - **selector, + records_key="data_requests", ) - if stac: - data_requests = [{**r, "stac_item": DataRequestPublic(**r).stac_item} for r in data_requests] - return {"data_requests": data_requests, "links": links} + response["data_requests"] = [ + {**r, "stac_item": DataRequestPublic(**r).stac_item} for r in response["data_requests"] + ] + return response diff --git a/marble_api/versions/v1/user_survey/__init__.py b/marble_api/versions/v1/survey/__init__.py similarity index 100% rename from marble_api/versions/v1/user_survey/__init__.py rename to marble_api/versions/v1/survey/__init__.py diff --git a/marble_api/versions/v1/survey/models.py b/marble_api/versions/v1/survey/models.py new file mode 100644 index 0000000..88772c7 --- /dev/null +++ b/marble_api/versions/v1/survey/models.py @@ -0,0 +1,239 @@ +from collections.abc import Sequence +from typing import Literal, Self + +from pydantic import ( + BaseModel, + Field, + FieldSerializationInfo, + PositiveInt, + field_serializer, + model_validator, +) +from pydantic.json_schema import SkipJsonSchema +from stac_pydantic.links import Links + +from marble_api.utils.models import ( + MarbleBaseModel, + MarbleBaseModelPublic, + MarbleBaseModelUpdate, + MarbleUserModel, + MarbleUserModelPublic, + MarbleUserModelUpdate, + PartialModel, + Pattern, + PyObjectId, +) + +type AnyAnswer = str | list[str] | None + + +class Question(BaseModel): + """ + Base class for survey questions. + + Question text is the text of the question itself. + The required field indicates if a SurveyResponse must contain an answer to this question. + """ + + text: str = Field(..., min_length=1) + required: bool = False + + def _validate_answer(self, answer: AnyAnswer) -> None: + """ + Validate the answer is valid for this question. + + Subclasses should extend this function if additional validation logic is required. + """ + + def validate_answer(self, answer: AnyAnswer) -> None: + """Validate the answer is valid for this question.""" + if self.required and answer is None: + raise ValueError("Answer is required for this question.") + if answer is not None: + self._validate_answer(answer) + + +class TextQuestion(Question): + """ + Question with a text (string) response. + + Specify minimum and maximum character length of the response. + Optionally specify a regular expression pattern that the answer must match. + """ + + question_type: Literal["text"] = "text" + min_length: int = Field(1, ge=1) + max_length: int = Field(1000, le=1000) # 1000 char limit to prevent abuse + pattern: Pattern | None = None + + @model_validator(mode="after") + def validate_length(self) -> Self: + """Check that length values make sense.""" + if self.min_length > self.max_length: + raise ValueError("max_length must be >= min_length") + return self + + def _validate_answer(self, answer: AnyAnswer) -> None: + """Check that an answer is valid for this question.""" + if not isinstance(answer, str): + raise ValueError(f"Answer '{answer}' must be a valid string.") + if not (self.min_length <= len(answer) <= self.max_length): + raise ValueError( + f"Answer '{answer}' must be between {self.min_length} and {self.max_length} characters long." + ) + if self.pattern is not None: + if self.pattern.search(answer) is None: + raise ValueError(f"Answer '{answer}' must match pattern '{self.pattern.pattern}'") + + +class TextQuestionUpdate(TextQuestion, PartialModel): + """Model to update a question with a text (string) response.""" + + # skip inherited model validators for partial models + validate_length = model_validator(mode="after")(lambda _: _) + + +class ChoiceQuestion(Question): + """ + Question with a multiple choice response. + + Specify minimum and maximum number of choices that a user can choose in a given answer. + + Specify the choices themselves as a dictionary where keys are unique choice IDs and values are the + choice text to present to the user. + + The allow_other boolean indicates that users are allowed to specify another option (other than those + specified in the choices dictionary). + + If the allow_other boolean is True, specify an optional regular expression pattern that the other option + must match and a minimum and maximum length of the other response. + """ + + question_type: Literal["choice"] = "choice" + min_choices: PositiveInt = 1 + max_choices: PositiveInt | None = 1 + choices: dict[str, str] = Field(..., min_length=1) + allow_others: bool = False + min_length: int = Field(1, ge=1) + max_length: int = Field(1000, le=1000) # 1000 char limit to prevent abuse + pattern: Pattern | None = None + + @model_validator(mode="after") + def validate_min_max_choices(self) -> Self: + """Check that length values make sense.""" + if self.max_choices is not None: + if self.min_choices > self.max_choices: + raise ValueError("max_choices must be None or >= min_choices") + return self + + @model_validator(mode="after") + def validate_min_choices(self) -> Self: + """Check that length values make sense.""" + if self.min_choices > len(self.choices): + raise ValueError( + f"specify at least min_choices ({self.min_choices}) choices. ({len(self.choices)}) specified." + ) + return self + + @model_validator(mode="after") + def validate_length(self) -> Self: + """Check that length values make sense.""" + if self.min_length > self.max_length: + raise ValueError("max_length must be >= min_length") + return self + + def _validate_answer(self, answer: AnyAnswer) -> None: + """Check that an answer is valid for this question.""" + if not ( + isinstance(answer, Sequence) and not isinstance(answer, str) and all(isinstance(r, str) for r in answer) + ): + raise ValueError(f"Answer '{answer}' must be a sequence of strings.") + if self.min_choices > len(answer): + raise ValueError(f"Answer must contain at least {self.min_choices} options. Found {len(answer)}") + if self.max_choices is not None and len(answer) > self.max_choices: + raise ValueError(f"Answer cannot contain more than {self.max_choices} options. Found {len(answer)}") + others = [r for r in answer if r not in self.choices] + if self.allow_others: + if not all(self.min_length <= len(o) <= self.max_length for o in others): + raise ValueError( + f"Other answers must be between {self.min_length} and {self.max_length} characters long." + ) + if self.pattern is not None: + if not all(self.pattern.search(o) for o in others): + raise ValueError(f"Other option in answer does not match the pattern {self.pattern}") + elif others: + raise ValueError(f"Answer contains options that are not valid choices for this question: {others}") + + +class ChoiceQuestionUpdate(ChoiceQuestion, PartialModel): + """Model to update a question with multiple choice response.""" + + # skip inherited model validators for partial models + validate_min_max_choices = model_validator(mode="after")(lambda _: _) + validate_min_choices = model_validator(mode="after")(lambda _: _) + validate_length = model_validator(mode="after")(lambda _: _) + + +class Survey(MarbleBaseModel): + """Survey containing questions.""" + + user_visible: bool + questions: list[TextQuestion | ChoiceQuestion] = Field(..., min_length=1) + + +class SurveyUpdate(MarbleBaseModelUpdate, Survey): + """Model to update a survey.""" + + +class SurveyPublic(MarbleBaseModelPublic, Survey): + """Model that shows publicly visible survey.""" + + +class SurveyResponse(MarbleUserModel): + """Responses to a survey.""" + + # survey_id is set by the route after the model is first validated + survey_id: SkipJsonSchema[PyObjectId | None] = None + answers: list[AnyAnswer] + + @field_serializer("survey_id") + def require_survey_id_set(self, value: str, info: FieldSerializationInfo) -> str: + """Require that the survey id be set when the model is serialized.""" + if not value: + raise ValueError(f"{info.field_name} must be set and non-empty") + return value + + def validate_response(self, survey: Survey) -> None: + """Check that all answers are valid according to the survey.""" + if self.survey_id != str(survey.id): + raise ValueError("Responses are not for this survey. Survey ids do not match.") + if len(self.answers) != len(survey.questions): + raise ValueError( + f"Survey has {len(survey.questions)} questions but only {len(self.answers)} answers given." + ) + for question, answer in zip(survey.questions, self.answers): + question.validate_answer(answer) + + +class SurveyResponseUpdate(MarbleUserModelUpdate, SurveyResponse): + """Model to update responses to a survey.""" + + +class SurveyResponsePublic(MarbleUserModelPublic, SurveyResponse): + """Model that shows publicly visible responses to a survey.""" + + survey_id: PyObjectId + + +class SurveysResponse(BaseModel): + """Response model for returning multiple surveys.""" + + surveys: list[SurveyPublic] + links: Links + + +class SurveyResponsesResponse(BaseModel): + """Response model for returning multiple surveys.""" + + survey_responses: list[SurveyResponsePublic] + links: Links diff --git a/marble_api/versions/v1/survey/routes.py b/marble_api/versions/v1/survey/routes.py new file mode 100644 index 0000000..95b36d7 --- /dev/null +++ b/marble_api/versions/v1/survey/routes.py @@ -0,0 +1,269 @@ +from typing import Annotated, Literal + +from bson import ObjectId +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response + +from marble_api.database import collection +from marble_api.utils.models import object_id +from marble_api.utils.routes import delete_record, get_record, get_records, patch_record, post_record +from marble_api.versions.v1.survey.models import ( + AnyAnswer, + ChoiceQuestionUpdate, + Survey, + SurveyPublic, + SurveyResponse, + SurveyResponsePublic, + SurveyResponsesResponse, + SurveyResponseUpdate, + SurveysResponse, + SurveyUpdate, + TextQuestionUpdate, +) + +survey_user_router = APIRouter(prefix="/surveys") +survey_admin_router = APIRouter(prefix="/surveys") +survey_response_admin_router = APIRouter(prefix="/survey-responses") + + +def _survey_id(id_: str) -> ObjectId: + return object_id(id_, HTTPException(status_code=404, detail=f"user survey with id={id_} not found")) + + +def _survey_response_id(id_: str) -> ObjectId: + return object_id(id_, HTTPException(status_code=404, detail=f"user survey response with id={id_} not found")) + + +_survey_collection = collection("survey") +_response_collection = collection("survey-response") + + +@survey_admin_router.post("/") +async def post_survey(survey: Survey) -> SurveyPublic: + """Create a new user survey and return the newly created user survey.""" + return await post_record(_survey_collection(), None, survey) + + +async def _check_survey_updatable(survey_id: str) -> None: + """ + Surveys cannot be updated once answers exist for that survey or it is visible. + + This ensures that surveys cannot be updated to make a response invalid after the fact. + """ + survey = await _survey_collection().find_one({"_id": ObjectId(survey_id)}) + if survey is None: + raise HTTPException(status_code=404, detail="Not found") + if survey.get("user_visible"): + raise HTTPException(status_code=403, detail="Unable to update a user-visible survey") + response = await _response_collection().find_one({"survey_id": survey_id}) + if response is not None: + raise HTTPException(status_code=403, detail="Unable to update a survey with existing responses") + + +@survey_admin_router.patch("/{survey_id}") +async def patch_survey(survey_id: str, survey: SurveyUpdate) -> SurveyPublic: + """Update fields of a user survey and return the updated user survey.""" + survey_update = survey.model_dump(exclude_unset=True, by_alias=True) + if set(survey_update) != {"user_visible"}: + # always allow updating the user_visible tag even if the survey is visible or has responses + await _check_survey_updatable(survey_id) + return await patch_record(_survey_collection(), _survey_id(survey_id), None, survey, SurveyPublic) + + +@survey_admin_router.patch("/{survey_id}/questions/{question_index}", dependencies=[Depends(_check_survey_updatable)]) +async def patch_survey_question( + survey_id: str, question_index: Annotated[int, Path(ge=0)], question: TextQuestionUpdate | ChoiceQuestionUpdate +) -> SurveyPublic: + """Update a single question of a user survey and return the updated user survey.""" + return await patch_record( + _survey_collection(), + _survey_id(survey_id), + None, + question, + SurveyPublic, + set_prefix=f"questions.{question_index}.", + ) + + +@survey_user_router.get("/{survey_id}") +async def get_survey(survey_id: str) -> SurveyPublic: + """Get a user survey with the given survey_id.""" + return await get_record( + _survey_collection(), _survey_id(survey_id), None, additional_selector={"user_visible": True} + ) + + +@survey_admin_router.get("/{survey_id}") +async def get_survey_(survey_id: str) -> SurveyPublic: + """Get a user survey with the given survey_id.""" + return await get_record(_survey_collection(), _survey_id(survey_id), None) + + +@survey_admin_router.delete("/{survey_id}", dependencies=[Depends(_check_survey_updatable)]) +async def delete_survey(survey_id: str) -> Response: + """Delete a user user survey with the given survey_id.""" + return await delete_record(_survey_collection(), _survey_id(survey_id), None) + + +@survey_user_router.get("/") +@survey_admin_router.get("/") +async def get_surveys( + request: Request, + user: str | None = None, + after: str | None = None, + before: str | None = None, + limit: Annotated[int, Query(le=100, gt=0)] = 10, + sort_by: Literal["id", "created", "updated", "user_visible"] = "id", + ascending: bool = True, +) -> SurveysResponse: + """ + Return all user surveys. + + This response is paginated and will only return at most limit objects at a time (maximum 100). + Use the offset and limit parameters to select specific ranges of user surveys. + """ + return await get_records( + collection=_survey_collection(), + request=request, + user=None, + after=_survey_id(after) if after else None, + before=_survey_id(before) if before else None, + limit=limit, + sort_by=sort_by, + ascending=ascending, + additional_selector=None if user is None else {"user_visible": True}, + records_key="surveys", + ) + + +async def _visible_survey(survey_id: str) -> Survey: + """Check that the survey exists and is user visible.""" + record = await get_record( # this will raise a 404 if the record is not found + _survey_collection(), + _survey_id(survey_id), + None, + additional_selector={"user_visible": True}, + ) + return Survey(**record) + + +def validate_response(survey: Survey, survey_response: SurveyResponse) -> None: + """Validate a survey response for a given survey and raise an HTTP error if invalid.""" + try: + survey_response.validate_response(survey) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +@survey_user_router.post("/{survey_id}/response") +async def post_survey_response( + user: str, survey_id: str, survey_response: SurveyResponse, survey: Annotated[Survey, Depends(_visible_survey)] +) -> SurveyResponsePublic: + """ + Create a new user survey response and return the newly created user survey response. + + A user is only allowed one response per survey. + """ + try: + await get_record(_response_collection(), None, user=user, additional_selector={"survey_id": survey_id}) + except HTTPException: + validate_response(survey, survey_response) + return await post_record(_response_collection(), user, survey_response) + raise HTTPException(status_code=409, detail="A response already exists for this survey. Use PUT to update instead.") + + +@survey_user_router.put("/{survey_id}/response") +async def put_survey_response( + survey_id: str, + survey_response: SurveyResponseUpdate, + user: str, + survey: Annotated[Survey, Depends(_visible_survey)], +) -> SurveyResponsePublic: + """Update fields of survey response and return the updated survey response.""" + # uses patch_record even though this is a PUT method because there is only one updatable field + updated_fields = survey_response.model_dump(exclude_unset=True) + if "survey_id" in updated_fields and survey_id != updated_fields["survey_id"]: + # do not allow updating survey_id field + raise HTTPException(status_code=403, detail="Forbidden") + if "answers" in updated_fields: + survey_response_synth = SurveyResponse(survey_id=survey_id, answers=updated_fields["answers"]) + validate_response(survey, survey_response_synth) + return await patch_record( + _response_collection(), + None, + user, + survey_response, + SurveyResponsePublic, + additional_selector={"survey_id": survey_id}, + ) + + +@survey_user_router.patch("/{survey_id}/response/{answer_index}") +async def patch_survey_response( + survey_id: str, + user: str, + answer_index: Annotated[int, Path(ge=0)], + survey: Annotated[Survey, Depends(_visible_survey)], + answer: AnyAnswer = None, # default None means leaving this blank will set this answer to None (not answered) +) -> SurveyResponsePublic: + """Update a single question of a user survey and return the updated user survey.""" + try: + survey.questions[answer_index].validate_answer(answer) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except IndexError: + raise HTTPException(status_code=404, detail=f"No question with index {answer_index} exists.") + return await patch_record( + _response_collection(), + None, + user, + {f"answers.{answer_index}": answer}, + SurveyResponsePublic, + additional_selector={"survey_id": survey_id}, + ) + + +@survey_user_router.get("/{survey_id}/response", dependencies=[Depends(_visible_survey)]) +async def get_survey_response(survey_id: str, user: str) -> SurveyResponsePublic: + """Return the user survey response for the given survey.""" + return await get_record(_response_collection(), None, user, additional_selector={"survey_id": survey_id}) + + +@survey_response_admin_router.get("/{response_id}") +async def get_survey_response_(response_id: str) -> SurveyResponsePublic: + """Return a user survey response.""" + return await get_record(_response_collection(), _survey_response_id(response_id), None) + + +@survey_response_admin_router.delete("/{response_id}") +async def delete_survey_response(response_id: str) -> Response: + """Delete a user survey response.""" + return await delete_record(_response_collection(), _survey_response_id(response_id), None) + + +@survey_response_admin_router.get("/") +async def get_survey_responses( + request: Request, + user: str | None = None, + after: str | None = None, + before: str | None = None, + limit: Annotated[int, Query(le=100, gt=0)] = 10, + sort_by: Literal["id", "created", "updated", "survey_id"] = "id", + ascending: bool = True, +) -> SurveyResponsesResponse: + """ + Return all user survey responses. + + This response is paginated and will only return at most limit objects at a time (maximum 100). + Use the offset and limit parameters to select specific ranges of user survey responses. + """ + return await get_records( + collection=_response_collection(), + request=request, + user=user, + after=_survey_response_id(after) if after else None, + before=_survey_response_id(before) if before else None, + limit=limit, + sort_by=sort_by, + ascending=ascending, + records_key="survey_responses", + ) diff --git a/marble_api/versions/v1/user_survey/models.py b/marble_api/versions/v1/user_survey/models.py deleted file mode 100644 index f8f2e58..0000000 --- a/marble_api/versions/v1/user_survey/models.py +++ /dev/null @@ -1,124 +0,0 @@ -import re -from abc import ABC, abstractmethod -from typing import Any, Literal, Self - -from pydantic import BaseModel, Field, PositiveInt, model_validator - -from marble_api.utils.models import ( - MarbleBaseModel, - MarbleBaseModelPublic, - MarbleBaseModelUpdate, - MarbleUserModel, - MarbleUserModelPublic, - MarbleUserModelUpdate, - partial_model, -) - - -class Question(BaseModel, ABC): - """ - Base class for survey questions. - - Question text is the text of the question itself. - The required field indicates if a SurveyResponse must contain an answer to this question. - """ - - text: str = Field(..., min_length=1) - required: bool = False - - @abstractmethod - def validate_response(self, response: Any) -> None: # noqa: ANN401 - """Validate the response is valid for this question.""" - raise NotImplementedError - - -class TextQuestion(Question): - """ - Question with a text (string) response. - - Specify minimum and maximum character length of the response. - Optionally specify a regular expression pattern that the answer must match. - """ - - question_type: Literal["text"] = "text" - min_length: int = Field(0, ge=0) - max_length: int = Field(1000, le=1000) # 1000 char limit to prevent abuse - pattern: re.Pattern | None = None - - @model_validator(mode="after") - def validate_length(self) -> Self: - """Check that length values make sense.""" - assert self.min_length <= self.max_length, "max_length must be >= min_length" - return self - - def validate_response(self, response: str) -> None: - """Validate that a response is valid for this question.""" - assert isinstance(response, str), "Response '{r}'" - - -class ChoiceQuestion(Question): - """ - Question with a multiple choice response. - - Specify minimum and maximum number of choices that a user can choose in a given answer. - - Specify the choices themselves as a dictionary where keys are unique choice IDs and values are the - choice text to present to the user. - - The allow_other boolean indicates that users are allowed to specify another option (other than those - specified in the choices dictionary). - - If the allow_other boolean is True, specify an optional regular expression pattern that the other option - must match. - """ - - question_type: Literal["choice"] = "choice" - min_choices: int = Field(1, ge=0) - max_choices: PositiveInt | None = 1 - choices: dict[str, str] = Field(..., min_length=1) - allow_other: bool = False - pattern: re.Pattern | None = None - - @model_validator(mode="after") - def validate_min_max_choices(self) -> Self: - """Check that length values make sense.""" - if self.max_choices is not None: - assert self.min_choices <= self.max_choices, "max_choices must be None or >= min_choices" - return self - - @model_validator(mode="after") - def validate_min_choices(self) -> Self: - """Check that length values make sense.""" - assert self.min_choices <= len(self.choices), f"specify at least min_choices ({self.min_choices}) choices" - return self - - -class Survey(MarbleBaseModel): - """Survey containing questions.""" - - survey: list[TextQuestion | ChoiceQuestion] - - -@partial_model -class SurveyUpdate(MarbleBaseModelUpdate, Survey): - """Model to update a survey.""" - - # TODO: ensure that can't be updated if there are associated responses. - # TODO: allow updating/viewing a survey by the index of the question - - -class SurveyPublic(MarbleBaseModelPublic, Survey): - """Model that shows publicly visible survey.""" - - -class SurveyResponse(MarbleUserModel): - """Response to a survey question.""" - - -@partial_model -class SurveyResponseUpdate(MarbleUserModelUpdate, SurveyResponse): - """Model to update a response to a survey question.""" - - -class SurveyResponsePublic(MarbleUserModelPublic, SurveyResponse): - """Model that shows publicly visible response to a survey question.""" diff --git a/pyproject.toml b/pyproject.toml index 3818fb3..84b828c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,4 +53,7 @@ convention = "numpy" [tool.pytest.ini_options] markers = [ "no_db_cleanup: tests with this mark will not clean up the database immediately after the test." -] \ No newline at end of file +] +addopts = [ + "--import-mode=importlib", +] diff --git a/test/conftest.py b/test/conftest.py index dee4ae8..3f8ff9f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,10 +5,11 @@ os.environ["MARBLE_API_MAGPIE_AUTH_ENABLED"] = "false" import pytest -from faker_providers import DataRequestProvider, GeoJsonProvider from marble_api._config import config +from .faker_providers import DataRequestProvider, GeoJsonProvider, SurveyProvider + @pytest.fixture(scope="session") def anyio_backend(): @@ -17,7 +18,11 @@ def anyio_backend(): @pytest.fixture(scope="session") def faker_providers(): - return {"DataRequestProvider": DataRequestProvider, "GeoJsonProvider": GeoJsonProvider} + return { + "DataRequestProvider": DataRequestProvider, + "GeoJsonProvider": GeoJsonProvider, + "SurveyProvider": SurveyProvider, + } @pytest.fixture diff --git a/test/faker_providers.py b/test/faker_providers.py index 513310d..c5d3f27 100644 --- a/test/faker_providers.py +++ b/test/faker_providers.py @@ -1,6 +1,4 @@ import bson -import pytest -from faker import Faker from faker.providers import BaseProvider from geojson_pydantic import ( Feature, @@ -15,6 +13,18 @@ ) from marble_api.versions.v1.data_request.models import DataRequest, DataRequestPublic, DataRequestUpdate +from marble_api.versions.v1.survey.models import ( + ChoiceQuestion, + ChoiceQuestionUpdate, + Survey, + SurveyPublic, + SurveyResponse, + SurveyResponsePublic, + SurveyResponseUpdate, + SurveyUpdate, + TextQuestion, + TextQuestionUpdate, +) class GeoJsonProvider(BaseProvider): @@ -181,7 +191,12 @@ def uncollapsible_geojson(self, dimensions=None): return self.generator.random.choice(self.uncollapsible_geojsons(dimensions)) -class DataRequestProvider(GeoJsonProvider): +class DatetimeProvider(BaseProvider): + def tz_aware_date_time_seconds_precision(self): + return self.generator.date_time(tzinfo=self.generator.pytimezone()).replace(microsecond=0) + + +class DataRequestProvider(GeoJsonProvider, DatetimeProvider): def author(self): author_ = {"last_name": self.generator.last_name()} if self.generator.pybool(): @@ -190,9 +205,6 @@ def author(self): author_["email"] = self.generator.email() return author_ - def tz_aware_date_time_seconds_precision(self): - return self.generator.date_time(tzinfo=self.generator.pytimezone()).replace(microsecond=0) - def temporal(self): opt = self.generator.random.random() if opt < 1 / 3: @@ -254,8 +266,170 @@ def data_request_update(self, unset=None, **kwargs): return DataRequestUpdate(**{**self._data_request_inputs(unset=unset), **kwargs}) -@pytest.fixture(scope="session") -def fake(): - fake_ = Faker() - fake_.add_provider(DataRequestProvider) - return fake_ +class SurveyProvider(DatetimeProvider): + example_pattern = r"^\w*\d*$" + + def question(self, unset=None, **kwargs): + return ( + self.text_question(unset=unset, **kwargs) + if self.generator.pybool() + else self.choice_question(unset=unset, **kwargs) + ) + + def question_update(self, unset=None, **kwargs): + return ( + self.text_question_update(unset=unset, **kwargs) + if self.generator.pybool() + else self.choice_question_update(unset=unset, **kwargs) + ) + + def _text_question_inputs(self, unset=None): + min_length = self.generator.pyint(min_value=1, max_value=1000) + max_length = self.generator.pyint(min_value=min_length, max_value=1000) + inputs = dict( + text=self.generator.sentence(), + required=self.generator.pybool(), + question_type="text", + min_length=min_length, + max_length=max_length, + pattern=[None, self.example_pattern][self.generator.pybool()], + ) + if unset: + for field in unset: + inputs.pop(field) + return inputs + + def text_question(self, unset=None, **kwargs): + return TextQuestion(**{**self._text_question_inputs(unset=unset), **kwargs}) + + def text_question_update(self, unset=None, **kwargs): + return TextQuestionUpdate(**{**self._text_question_inputs(unset=unset), **kwargs}) + + def _choice_question_inputs(self, unset=None): + min_choices = self.generator.pyint(min_value=1, max_value=10) + max_choices = self.generator.pyint(min_value=min_choices, max_value=10) if self.generator.pybool() else None + min_length = self.generator.pyint(min_value=1, max_value=1000) + max_length = self.generator.pyint(min_value=min_length, max_value=1000) + inputs = dict( + text=self.generator.sentence(), + required=self.generator.pybool(), + question_type="choice", + min_choices=min_choices, + max_choices=max_choices, + min_length=min_length, + max_length=max_length, + choices=self.generator.pydict( + self.generator.pyint(min_value=min_choices, max_value=10), variable_nb_elements=False, value_types=[str] + ), + allow_others=self.generator.pybool(), + pattern=[None, self.example_pattern][self.generator.pybool()], + ) + if unset: + for field in unset: + inputs.pop(field) + return inputs + + def choice_question(self, unset=None, **kwargs): + return ChoiceQuestion(**{**self._choice_question_inputs(unset=unset), **kwargs}) + + def choice_question_update(self, unset=None, **kwargs): + return ChoiceQuestionUpdate(**{**self._choice_question_inputs(unset=unset), **kwargs}) + + def _survey_inputs(self, unset=None): + inputs = dict( + id=bson.ObjectId(), + updated=self.generator.tz_aware_date_time_seconds_precision(), + user=self.generator.profile("username")["username"], + user_visible=self.generator.pybool(), + questions=[self.question() for _ in range(self.generator.random.randint(1, 10))], + ) + if unset: + for field in unset: + inputs.pop(field) + return inputs + + def survey(self, unset=None, **kwargs): + return Survey(**{**self._survey_inputs(unset=unset), **kwargs}) + + def survey_public(self, unset=None, **kwargs): + return SurveyPublic(**{**self._survey_inputs(unset=unset), **kwargs}) + + def survey_update(self, unset=None, **kwargs): + return SurveyUpdate(**{**self._survey_inputs(unset=unset), **kwargs}) + + def _string_answer_value(self, min_length, max_length, pattern): + if pattern is None: + return self.generator.pystr(min_chars=min_length, max_chars=max_length) + else: + # pattern is assumed to always match example_pattern + format = "?" * self.generator.pyint(max_value=max_length) + format += "#" * self.generator.pyint( + min_value=max(0, min_length - len(format)), + max_value=max(0, max_length - len(format)), + ) + return self.generator.pystr_format(format) + + # Note: there is a 10% chance that a non-required answer will be None + def text_answer(self, for_question: TextQuestion | None = None): + if for_question is None: + return self.generator.pystr(max_chars=10) if self.generator.pybool(90) else None + if for_question.required or self.generator.pybool(90): + return self._string_answer_value(for_question.min_length, for_question.max_length, for_question.pattern) + + def choice_answer(self, for_question: ChoiceQuestion | None = None): + if for_question is None: + if self.generator.pybool(90): + return [self.generator.pystr(max_chars=10) for _ in range(self.generator.pyint(10))] + return + if not for_question.required and self.generator.pybool(10): + return + max_choices = for_question.max_choices or len(for_question.choices) + answers = self.generator.words( + nb=self.generator.pyint(for_question.min_choices, max_choices), + ext_word_list=list(for_question.choices), + ) + if for_question.allow_others: + answers += [ + self._string_answer_value(for_question.min_length, for_question.max_length, for_question.pattern) + for _ in range( + self.generator.pyint( + min_value=max(0, for_question.min_choices - len(answers)), + max_value=max(0, max_choices - len(answers)), + ) + ) + ] + return answers + + def answer(self, for_question=None): + if for_question: + gen_text = for_question.question_type == "text" + else: + gen_text = self.generator.pybool() + + ans_generator = self.generator.text_answer if gen_text else self.generator.choice_answer + return ans_generator(for_question=for_question) + + def _response_inputs(self, unset=None, for_survey=None): + inputs = dict( + id=bson.ObjectId(), + updated=self.generator.tz_aware_date_time_seconds_precision(), + user=self.generator.profile("username")["username"], + survey_id=for_survey.id if for_survey else bson.ObjectId(), + answers=[self.answer(for_question=question) for question in for_survey.questions] + if for_survey + else [self.answer() for _ in range(self.generator.random.randint(1, 10))], + ) + + if unset: + for field in unset: + inputs.pop(field) + return inputs + + def response(self, unset=None, for_survey=None, **kwargs): + return SurveyResponse(**{**self._response_inputs(unset=unset, for_survey=for_survey), **kwargs}) + + def response_public(self, unset=None, for_survey=None, **kwargs): + return SurveyResponsePublic(**{**self._response_inputs(unset=unset, for_survey=for_survey), **kwargs}) + + def response_update(self, unset=None, for_survey=None, **kwargs): + return SurveyResponseUpdate(**{**self._response_inputs(unset=unset, for_survey=for_survey), **kwargs}) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 4a316c8..e3dd587 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -8,7 +8,7 @@ @pytest.fixture(scope="session", autouse=True) -def init_test_db(): +async def init_test_db(): # use different default database name from prod/dev in order to minimize the chance # of accidentally using a prod/dev database. client.get_default_database = functools.partial(client.get_default_database, default="marble-api-test") @@ -17,9 +17,9 @@ def init_test_db(): @pytest.fixture(scope="session", autouse=True) async def check_empty_test_db(init_test_db): database = client.db - if await database.list_collection_names(): + if collections := await database.list_collection_names(): raise RuntimeError( - f"Database {database.name} contains some collections. Tests must be run on an empty database." + f"Database {database.name} contains some collections: {collections}. Tests must be run on an empty database." ) diff --git a/test/integration/utils/_test_auth.py b/test/integration/utils/_test_auth.py new file mode 100644 index 0000000..f15bb62 --- /dev/null +++ b/test/integration/utils/_test_auth.py @@ -0,0 +1,63 @@ +from typing import Literal + +import httpx +import pytest + + +class AuthEnabledTest: + request_method: str + request_path_type: Literal["member", "collection"] + auth_type: Literal["user", "admin"] + + @pytest.fixture + async def make_request(self, async_client, member_route, collection_route): + route = member_route if self.request_path_type == "member" else collection_route + return getattr(async_client, self.request_method.lower())(route) + + async def test_authenticate_failure_unauthorized(self, auth_mock, make_request): + auth_mock.mock(return_value=httpx.Response(200, json={"authenticated": False})) + response = await make_request + assert response.status_code == 403, response.json() + + async def test_authenticate_failure_upstream(self, auth_mock, make_request): + auth_mock.mock(return_value=httpx.Response(500, json={})) + response = await make_request + assert response.status_code == 403, response.json() + + +class UserAuthTest(AuthEnabledTest): + auth_type: str = "user" + + async def test_authenticate_failure_different_user(self, auth_mock, user, make_request): + auth_mock.mock( + return_value=httpx.Response(200, json={"authenticated": True, "user": {"user_name": user + "suffix"}}) + ) + response = await make_request + assert response.status_code == 403, response.json() + + async def test_authenticate_success(self, auth_mock, user, make_request, member_route, records): + auth_mock.mock(return_value=httpx.Response(200, json={"authenticated": True, "user": {"user_name": user}})) + response = await make_request + # note that 422 is ok here because we're just checking that the authentication worked, not that the + # request was well formed otherwise + assert response.status_code < 300 or response.status_code == 422, member_route + + +class AdminAuthTest(AuthEnabledTest): + auth_type: str = "admin" + + async def test_authenticate_failure_not_admin(self, auth_mock, make_request): + auth_mock.mock(return_value=httpx.Response(200, json={"authenticated": True, "user": {"group_names": []}})) + response = await make_request + assert response.status_code == 403 + + async def test_authenticate_success(self, auth_mock, make_request, test_config): + auth_mock.mock( + return_value=httpx.Response( + 200, json={"authenticated": True, "user": {"group_names": [test_config.magpie_admin_group]}} + ) + ) + response = await make_request + # note that 422 is ok here because we're just checking that the authentication worked, not that the + # request was well formed otherwise + assert response.status_code < 300 or response.status_code == 422 diff --git a/test/integration/utils/_test_routes.py b/test/integration/utils/_test_routes.py new file mode 100644 index 0000000..94ed697 --- /dev/null +++ b/test/integration/utils/_test_routes.py @@ -0,0 +1,242 @@ +import datetime +import json +from urllib.parse import parse_qs, urlparse + +import bson +import pytest + +from marble_api.database import client + + +def compare_no_timestamps(dict1, dict2, message=""): + timestamps = {"created", "updated"} + assert {k: v for k, v in dict1.items() if k not in timestamps} == { + k: v for k, v in dict2.items() if k not in timestamps + }, message + + +async def assert_db_not_changed(make_request, collection): + before = await client.db.get_collection(collection).find({}).to_list() + result = await make_request + after = await client.db.get_collection(collection).find({}).to_list() + assert before == after, "database has changed and it should not have" + return result + + +class PreloadData: + n_records = 2 + collection_name: str + + @pytest.fixture(scope="class", autouse=True) + @classmethod + async def load_data(cls, fake_class): + if "user" in fake_class().model_dump(): + kwargs1 = {"user": "user1"} + kwargs2 = {"user": "user2"} + else: + kwargs1 = kwargs2 = {} + data = [json.loads(fake_class(**kwargs1).model_dump_json()) for _ in range(cls.n_records // 2)] + [ + json.loads(fake_class(**kwargs2).model_dump_json()) for _ in range(cls.n_records - cls.n_records // 2) + ] + await client.db.get_collection(cls.collection_name).insert_many(data) + + @pytest.fixture(scope="class", autouse=True) + @classmethod + async def cleanup(cls): + try: + yield + finally: + await client.drop_database(client.db.name) + + @pytest.fixture(scope="class") + @classmethod + async def records(cls): + yield await client.db.get_collection(cls.collection_name).find({}).to_list() + + +class GetManyTest: + default_link_limit: int + n_records: int + n_records_return_count: int + records_key: str + sort_keys: tuple[str] + + async def get_all_data(self, async_client, route): + response = await async_client.get(route) + data = [] + while True: + data.extend(response.json()[self.records_key]) + for link in response.json()["links"]: + if link["rel"] == "next": + response = await async_client.get(link["href"]) + break + else: + break + return data + + async def test_get_limit_default(self, async_client, collection_route): + response = await assert_db_not_changed(async_client.get(collection_route), self.collection_name) + assert len(response.json()[self.records_key]) == self.default_link_limit + + async def test_get_limit_non_default(self, async_client, collection_route): + response = await assert_db_not_changed(async_client.get(f"{collection_route}?limit=5"), self.collection_name) + assert len(response.json()[self.records_key]) == 5 + + async def test_get_limit_more(self, async_client, collection_route): + response = await assert_db_not_changed( + async_client.get(f"{collection_route}?limit={self.n_records + 1}"), self.collection_name + ) + assert len(response.json()[self.records_key]) == self.n_records_return_count + + async def test_get_limit_none(self, async_client, collection_route): + response = await assert_db_not_changed(async_client.get(f"{collection_route}?limit=0"), self.collection_name) + assert response.status_code == 422 + + async def test_get_limit_over_max(self, async_client, collection_route): + response = await assert_db_not_changed(async_client.get(f"{collection_route}?limit=200"), self.collection_name) + assert response.status_code == 422 + + @pytest.mark.parametrize("ascending", [True, False]) + async def test_sort_order_single_page(self, async_client, collection_route, ascending): + for sort_by in self.sort_keys: + response = await assert_db_not_changed( + async_client.get(f"{collection_route}?sort_by={sort_by}&ascending={ascending}"), self.collection_name + ) + data = [req[sort_by] for req in response.json()[self.records_key]] + assert data == sorted(data, reverse=(not ascending)) + + @pytest.mark.parametrize("ascending", [True, False]) + async def test_sort_order_multi_page(self, async_client, collection_route, ascending): + for sort_by in self.sort_keys: + data = await assert_db_not_changed( + self.get_all_data(async_client, f"{collection_route}?sort_by={sort_by}&ascending={ascending}"), + self.collection_name, + ) + data = [(req[sort_by], req["id"]) for req in data] + assert len(data) == self.n_records_return_count # all data found + assert len(data) == len(set(data)) # no duplicates + assert data == sorted(data, reverse=(not ascending)) # in correct order + + async def test_get_first_page_links(self, async_client, collection_route): + response = await assert_db_not_changed(async_client.get(collection_route), self.collection_name) + links = response.json()["links"] + assert len(links) == 1 + link = links[0] + assert link["rel"] == "next" + assert link["type"] == "application/json" + assert link["href"].startswith(str(response.url)) + assert (after_id := parse_qs(urlparse(link["href"]).query).get("after")) + assert after_id not in [r["id"] for r in response.json()[self.records_key]] + + async def test_get_last_page_links(self, async_client, collection_route): + response = await assert_db_not_changed( + async_client.get(f"{collection_route}?limit={self.n_records_return_count - 3}"), self.collection_name + ) + next_link = next(link for link in response.json()["links"] if link["rel"] == "next") + response2 = await async_client.get(next_link["href"]) + links = response2.json()["links"] + assert len(links) == 1 + link = links[0] + assert link["rel"] == "prev" + assert link["type"] == "application/json" + assert link["href"].startswith(str(response.url)) + assert (before_id := parse_qs(urlparse(link["href"]).query).get("before")) + assert before_id not in [r["id"] for r in response.json()[self.records_key]] + + async def test_get_mid_page_links(self, async_client, collection_route): + response = await assert_db_not_changed(async_client.get(f"{collection_route}?limit=4"), self.collection_name) + next_link = next(link for link in response.json()["links"] if link["rel"] == "next") + response2 = await async_client.get(next_link["href"]) + links = response2.json()["links"] + assert len(links) == 2 + assert {link["rel"] for link in links} == {"prev", "next"} + for link in links: + assert link["type"] == "application/json" + assert link["href"].startswith(str(response.url)) + assert parse_qs(urlparse(link["href"]).query).get("limit") == ["4"] + if link["rel"] == "prev": + assert (id_ := parse_qs(urlparse(link["href"]).query).get("before")) + elif link["rel"] == "next": + assert (id_ := parse_qs(urlparse(link["href"]).query).get("after")) + assert id_ not in [r["id"] for r in response.json()[self.records_key]] + + async def test_next_prev_is_consistent(self, async_client, collection_route): + response = await assert_db_not_changed(async_client.get(f"{collection_route}?limit=4"), self.collection_name) + # page0 -> page1 + next_link = next(link for link in response.json()["links"] if link["rel"] == "next") + next_response = await assert_db_not_changed(async_client.get(next_link["href"]), self.collection_name) + # page0 -> page1 -> page2 + next_next_link = next(link for link in next_response.json()["links"] if link["rel"] == "next") + next_next_response = await assert_db_not_changed(async_client.get(next_next_link["href"]), self.collection_name) + # page0 -> page1 -> page0 + next_prev_link = next(link for link in next_response.json()["links"] if link["rel"] == "prev") + next_prev_response = await assert_db_not_changed(async_client.get(next_prev_link["href"]), self.collection_name) + # page0 -> page1 -> page2 -> page1 + next_next_prev_link = next(link for link in next_next_response.json()["links"] if link["rel"] == "prev") + next_next_prev_response = await assert_db_not_changed( + async_client.get(next_next_prev_link["href"]), self.collection_name + ) + # page0 -> page1 -> page2 -> page1 -> page0 + next_next_prev_prev_link = next( + link for link in next_next_prev_response.json()["links"] if link["rel"] == "prev" + ) + next_next_prev_prev_response = await assert_db_not_changed( + async_client.get(next_next_prev_prev_link["href"]), self.collection_name + ) + assert response.json() == next_prev_response.json() == next_next_prev_prev_response.json() + assert next_response.json() == next_next_prev_response.json() + + async def test_get_all_same_as_paging_next(self, async_client, collection_route): + all_response = await assert_db_not_changed( + async_client.get(f"{collection_route}?limit={self.n_records}"), self.collection_name + ) + next_link = [f"{collection_route}?limit=4"] + records = [] + while next_link: + response = await assert_db_not_changed(async_client.get(next_link[0]), self.collection_name) + records.extend(response.json()[self.records_key]) + next_link = [link["href"] for link in response.json()["links"] if link["rel"] == "next"] + assert all_response.json()[self.records_key] == records + + +class UpdateTest: + @pytest.fixture + def client_method(self, async_client): + return getattr(async_client, self.request_method.lower()) + + async def test_no_id_update(self, loaded_data, client_method, member_route): + update = {"id": str(bson.ObjectId())} + response = await assert_db_not_changed(client_method(member_route, json=update), self.collection_name) + assert response.status_code == 200 + assert response.json()["id"] == loaded_data["id"] + assert response.json()["id"] != update["id"] + compare_no_timestamps(loaded_data, response.json()) + + async def test_update_nothing(self, loaded_data, client_method, member_route): + response = await assert_db_not_changed(client_method(member_route, json={}), self.collection_name) + assert response.status_code == 200 + compare_no_timestamps(loaded_data, response.json()) + + async def test_bad_id(self, client_method, member_route, records): + resp = await assert_db_not_changed( + client_method(member_route.replace(records[0]["_id"], str(bson.ObjectId())), json={}), self.collection_name + ) + assert resp.status_code == 404, resp.json() + + async def test_created_in_response(self, client_method, member_route, valid_update): + response = await client_method(member_route, json=valid_update) + assert response.status_code == 200, response.json() + assert response.json()["created"] + + async def test_updated_updated(self, loaded_data, client_method, member_route, valid_update): + response = await client_method(member_route, json=valid_update) + assert response.status_code == 200, response.url + assert loaded_data["updated"] != response.json()["updated"], response.json() + + @pytest.mark.parametrize("field", ["created", "updated"]) + async def test_no_updatable_timestamps(self, client_method, member_route, field, valid_update): + new_date = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=10) + valid_update[field] = new_date.isoformat() + response = await client_method(member_route, json=valid_update) + assert response.status_code == 200 + assert response.json()[field] != new_date.isoformat() diff --git a/test/integration/versions/v1/data_request/test_routes.py b/test/integration/versions/v1/data_request/test_routes.py index b400556..4a627fb 100644 --- a/test/integration/versions/v1/data_request/test_routes.py +++ b/test/integration/versions/v1/data_request/test_routes.py @@ -1,11 +1,7 @@ -import datetime import inspect import json -from typing import Literal -from urllib.parse import parse_qs, urlparse import bson -import httpx import pytest from stac_pydantic import Item @@ -13,146 +9,77 @@ from marble_api.versions.v1.data_request.models import DataRequestPublic from marble_api.versions.v1.data_request.routes import get_data_requests -pytestmark = pytest.mark.anyio - - -def compare_no_timestamps(dict1, dict2, message=""): - timestamps = {"created", "updated"} - assert {k: v for k, v in dict1.items() if k not in timestamps} == { - k: v for k, v in dict2.items() if k not in timestamps - }, message +from .....integration.utils._test_auth import AdminAuthTest, UserAuthTest +from .....integration.utils._test_routes import ( + GetManyTest, + PreloadData, + UpdateTest, + assert_db_not_changed, + compare_no_timestamps, +) +pytestmark = pytest.mark.anyio -class _TestAuthEnabled: - request_method: str - request_path_type: Literal["member", "collection"] - auth_type: Literal["user", "admin"] +class _TestUser(UserAuthTest): @pytest.fixture - async def make_request(self, async_client, member_route, collection_route): - route = member_route if self.request_path_type == "member" else collection_route - return getattr(async_client, self.request_method.lower())(route) - - async def test_authenticate_success(self, auth_mock, data_requests, make_request, test_config): - if self.auth_type == "user": - auth_mock.mock( - return_value=httpx.Response( - 200, json={"authenticated": True, "user": {"user_name": data_requests[0]["user"]}} - ) - ) - else: - auth_mock.mock( - return_value=httpx.Response( - 200, json={"authenticated": True, "user": {"group_names": [test_config.magpie_admin_group]}} - ) - ) - response = await make_request - # note that 422 is ok here because we're just checking that the authentication worked, not that the - # request was well formed otherwise - assert response.status_code < 300 or response.status_code == 422 - - async def test_authenticate_failure_unauthorized(self, auth_mock, make_request): - auth_mock.mock(return_value=httpx.Response(200, json={"authenticated": False})) - response = await make_request - assert response.status_code == 403 - - async def test_authenticate_failure_upstream(self, auth_mock, make_request): - auth_mock.mock(return_value=httpx.Response(500, json={})) - response = await make_request - assert response.status_code == 403 - - -class _TestUser(_TestAuthEnabled): - auth_type: str = "user" + def user(self, records): + return records[0]["user"] @pytest.fixture - def member_route(self, data_requests): - return ( - f"/v1/users/{data_requests[0]['user']}/data-requests/{data_requests[0].get('_id', 'should not get here')}" - ) + def member_route(self, records, user): + return f"/v1/users/{user}/data-requests/{records[0].get('_id', 'should not get here')}" @pytest.fixture - def collection_route(self, data_requests): - return f"/v1/users/{data_requests[0]['user']}/data-requests/" - - async def test_authenticate_failure_different_user(self, auth_mock, data_requests, make_request): - auth_mock.mock( - return_value=httpx.Response( - 200, json={"authenticated": True, "user": {"user_name": data_requests[0]["user"] + "suffix"}} - ) - ) - response = await make_request - assert response.status_code == 403 + def collection_route(self, user): + return f"/v1/users/{user}/data-requests/" -class _TestAdmin(_TestAuthEnabled): - auth_type: str = "admin" - +class _TestAdmin(AdminAuthTest): @pytest.fixture - def member_route(self, data_requests): - return f"/v1/admin/data-requests/{data_requests[0].get('_id', 'should not get here')}" + def member_route(self, records): + return f"/v1/admin/data-requests/{records[0].get('_id', 'should not get here')}" @pytest.fixture def collection_route(self): return "/v1/admin/data-requests/" - async def test_authenticate_failure_not_admin(self, auth_mock, make_request): - auth_mock.mock(return_value=httpx.Response(200, json={"authenticated": True, "user": {"group_names": []}})) - response = await make_request - assert response.status_code == 403 - -class _TestGet: - n_data_requests = 2 +class _TestGet(PreloadData): request_method = "get" - - @pytest.fixture(scope="class", autouse=True) - @classmethod - async def load_data(cls, fake): - data = [fake.data_request(user="user1").model_dump() for _ in range(cls.n_data_requests // 2)] + [ - fake.data_request(user="user2").model_dump() for _ in range(cls.n_data_requests - cls.n_data_requests // 2) - ] - await client.db.get_collection("data-request").insert_many(data) - - @pytest.fixture(scope="class", autouse=True) - @classmethod - async def cleanup(cls): - try: - yield - finally: - await client.drop_database(client.db.name) + collection_name = "data-request" @pytest.fixture(scope="class") @classmethod - async def data_requests(cls): - yield await client.db.get_collection("data-request").find({}).to_list() + def fake_class(cls, fake): + return fake.data_request -class _TestGetOne(_TestGet, _TestAuthEnabled): +class _TestGetOne(_TestGet): request_path_type = "member" - async def test_get(self, async_client, data_requests, member_route): - resp = await async_client.get(member_route) + async def test_get(self, async_client, records, member_route): + resp = await assert_db_not_changed(async_client.get(member_route), self.collection_name) assert resp.status_code == 200 - assert DataRequestPublic(**data_requests[0]).model_dump() == DataRequestPublic(**resp.json()).model_dump() + assert DataRequestPublic(**records[0]).model_dump() == DataRequestPublic(**resp.json()).model_dump() async def test_get_stac(self, async_client, member_route): - resp = await async_client.get(f"{member_route}?stac=true") + resp = await assert_db_not_changed(async_client.get(f"{member_route}?stac=true"), self.collection_name) assert resp.status_code == 200 assert (item := resp.json().get("stac_item")) Item(**item) async def test_bad_id(self, async_client, member_route): invalid_route = "/".join(member_route.split("/")[:-1] + ["some-bad-id"]) - resp = await async_client.get(invalid_route) + resp = await assert_db_not_changed(async_client.get(invalid_route), self.collection_name) assert resp.status_code == 404 @pytest.mark.no_db_cleanup class TestGetOneUser(_TestGetOne, _TestUser): - async def test_bad_user(self, async_client, data_requests): - invalid_route = f"/v1/users/{data_requests[0]['user'] + '-bad-user'}/data-requests/{data_requests[0]['_id']}" - resp = await async_client.get(invalid_route) + async def test_bad_user(self, async_client, records): + invalid_route = f"/v1/users/{records[0]['user'] + '-bad-user'}/data-requests/{records[0]['_id']}" + resp = await assert_db_not_changed(async_client.get(invalid_route), self.collection_name) assert resp.status_code == 404 @@ -160,186 +87,74 @@ async def test_bad_user(self, async_client, data_requests): class TestGetOneAdmin(_TestGetOne, _TestAdmin): ... -class _TestGetMany(_TestGet): +class _TestGetMany(_TestGet, GetManyTest): request_path_type = "collection" default_link_limit = inspect.signature(get_data_requests).parameters["limit"].default - n_data_requests = default_link_limit * 2 + 2 - n_data_requests_return_count: int - - async def get_all_data(self, async_client, route): - response = await async_client.get(route) - data = [] - while True: - data.extend(response.json()["data_requests"]) - for link in response.json()["links"]: - if link["rel"] == "next": - response = await async_client.get(link["href"]) - break - else: - break - return data - - async def test_get(self, async_client, data_requests, collection_route): - response = await async_client.get(collection_route) - models = {str(req["_id"]): DataRequestPublic(**req).model_dump() for req in data_requests} + n_records = default_link_limit * 2 + 2 + n_records_return_count: int + records_key = "data_requests" + sort_keys = ("id", "user", "contact", "title", "created", "updated") + + async def test_get(self, async_client, records, collection_route): + response = await assert_db_not_changed(async_client.get(collection_route), self.collection_name) + + models = {str(req["_id"]): DataRequestPublic(**req).model_dump() for req in records} for req in response.json()["data_requests"]: assert DataRequestPublic(**req).model_dump() == models[req["id"]] async def test_get_stac(self, async_client, collection_route): - resp = await async_client.get(f"{collection_route}?stac=true") + resp = await assert_db_not_changed(async_client.get(f"{collection_route}?stac=true"), self.collection_name) + for req in resp.json()["data_requests"]: assert (item := req.get("stac_item")) Item(**item) - async def test_get_limit_default(self, async_client, collection_route): - response = await async_client.get(collection_route) - assert len(response.json()["data_requests"]) == self.default_link_limit - - async def test_get_limit_non_default(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit=5") - assert len(response.json()["data_requests"]) == 5 - - async def test_get_limit_more(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit={self.n_data_requests + 1}") - assert len(response.json()["data_requests"]) == self.n_data_requests_return_count - - async def test_get_limit_none(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit=0") - assert response.status_code == 422 - - async def test_get_limit_over_max(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit=200") - assert response.status_code == 422 - - @pytest.mark.parametrize("sort_by", ["id", "user", "contact", "title", "created", "updated"]) - @pytest.mark.parametrize("ascending", [True, False]) - async def test_sort_order_single_page(self, async_client, collection_route, sort_by, ascending): - response = await async_client.get(f"{collection_route}?sort_by={sort_by}&ascending={ascending}") - data = [req[sort_by] for req in response.json()["data_requests"]] - assert data == sorted(data, reverse=(not ascending)) - - @pytest.mark.parametrize("sort_by", ["id", "user", "contact", "title", "created", "updated"]) - @pytest.mark.parametrize("ascending", [True, False]) - async def test_sort_order_multi_page(self, async_client, collection_route, sort_by, ascending): - data = await self.get_all_data(async_client, f"{collection_route}?sort_by={sort_by}&ascending={ascending}") - data = [(req[sort_by], req["id"]) for req in data] - assert len(data) == self.n_data_requests_return_count # all data found - assert len(data) == len(set(data)) # no duplicates - assert data == sorted(data, reverse=(not ascending)) # in correct order - - async def test_get_first_page_links(self, async_client, collection_route): - response = await async_client.get(collection_route) - links = response.json()["links"] - assert len(links) == 1 - link = links[0] - assert link["rel"] == "next" - assert link["type"] == "application/json" - assert link["href"].startswith(str(response.url)) - assert (after_id := parse_qs(urlparse(link["href"]).query).get("after")) - assert after_id not in [r["id"] for r in response.json()["data_requests"]] - - async def test_get_last_page_links(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit={self.n_data_requests_return_count - 3}") - next_link = next(link for link in response.json()["links"] if link["rel"] == "next") - response2 = await async_client.get(next_link["href"]) - links = response2.json()["links"] - assert len(links) == 1 - link = links[0] - assert link["rel"] == "prev" - assert link["type"] == "application/json" - assert link["href"].startswith(str(response.url)) - assert (before_id := parse_qs(urlparse(link["href"]).query).get("before")) - assert before_id not in [r["id"] for r in response.json()["data_requests"]] - - async def test_get_mid_page_links(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit=4") - next_link = next(link for link in response.json()["links"] if link["rel"] == "next") - response2 = await async_client.get(next_link["href"]) - links = response2.json()["links"] - assert len(links) == 2 - assert {link["rel"] for link in links} == {"prev", "next"} - for link in links: - assert link["type"] == "application/json" - assert link["href"].startswith(str(response.url)) - assert parse_qs(urlparse(link["href"]).query).get("limit") == ["4"] - if link["rel"] == "prev": - assert (id_ := parse_qs(urlparse(link["href"]).query).get("before")) - elif link["rel"] == "next": - assert (id_ := parse_qs(urlparse(link["href"]).query).get("after")) - assert id_ not in [r["id"] for r in response.json()["data_requests"]] - - async def test_next_prev_is_consistent(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit=4") - # page0 -> page1 - next_link = next(link for link in response.json()["links"] if link["rel"] == "next") - next_response = await async_client.get(next_link["href"]) - # page0 -> page1 -> page2 - next_next_link = next(link for link in next_response.json()["links"] if link["rel"] == "next") - next_next_response = await async_client.get(next_next_link["href"]) - # page0 -> page1 -> page0 - next_prev_link = next(link for link in next_response.json()["links"] if link["rel"] == "prev") - next_prev_response = await async_client.get(next_prev_link["href"]) - # page0 -> page1 -> page2 -> page1 - next_next_prev_link = next(link for link in next_next_response.json()["links"] if link["rel"] == "prev") - next_next_prev_response = await async_client.get(next_next_prev_link["href"]) - # page0 -> page1 -> page2 -> page1 -> page0 - next_next_prev_prev_link = next( - link for link in next_next_prev_response.json()["links"] if link["rel"] == "prev" - ) - next_next_prev_prev_response = await async_client.get(next_next_prev_prev_link["href"]) - assert response.json() == next_prev_response.json() == next_next_prev_prev_response.json() - assert next_response.json() == next_next_prev_response.json() - - async def test_get_all_same_as_paging_next(self, async_client, collection_route): - all_response = await async_client.get(f"{collection_route}?limit={self.n_data_requests}") - next_link = [f"{collection_route}?limit=4"] - data_requests = [] - while next_link: - response = await async_client.get(next_link[0]) - data_requests.extend(response.json()["data_requests"]) - next_link = [link["href"] for link in response.json()["links"] if link["rel"] == "next"] - assert all_response.json()["data_requests"] == data_requests - @pytest.mark.no_db_cleanup class TestGetManyUser(_TestGetMany, _TestUser): - n_data_requests_return_count = _TestGetMany.n_data_requests // 2 + n_records_return_count = _TestGetMany.n_records // 2 async def test_get_all(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit={self.n_data_requests}") - assert len(response.json()["data_requests"]) == self.n_data_requests // 2 + response = await assert_db_not_changed( + async_client.get(f"{collection_route}?limit={self.n_records}"), self.collection_name + ) + assert len(response.json()["data_requests"]) == self.n_records // 2 @pytest.mark.no_db_cleanup class TestGetManyAdmin(_TestGetMany, _TestAdmin): - n_data_requests_return_count = _TestGetMany.n_data_requests + n_records_return_count = _TestGetMany.n_records async def test_get_all(self, async_client, collection_route): - response = await async_client.get(f"{collection_route}?limit={self.n_data_requests}") - assert len(response.json()["data_requests"]) == self.n_data_requests + response = await assert_db_not_changed( + async_client.get(f"{collection_route}?limit={self.n_records}"), self.collection_name + ) + + assert len(response.json()["data_requests"]) == self.n_records class _TestPost: request_method = "post" request_path_type = "collection" + collection_name = "data-request" @pytest.fixture - def data_requests(self): + def records(self): return [{"user": "user1"}] - async def test_valid(self, fake, async_client, collection_route, data_requests): + async def test_valid(self, fake, async_client, collection_route, records): data = fake.data_request().model_dump_json(exclude=["user"]) response = await async_client.post(collection_route, json=json.loads(data)) assert response.status_code == 200 response_data = response.json() assert (id_ := response_data.pop("id", None)) bson.ObjectId(id_) # check that the id is a valid object id - compare_no_timestamps({"user": data_requests[0]["user"], **json.loads(data)}, response_data) + compare_no_timestamps({"user": records[0]["user"], **json.loads(data)}, response_data) async def test_invalid_authors(self, fake, async_client, collection_route): data = json.loads(fake.data_request().model_dump_json()) data["authors"] = [] - response = await async_client.post(collection_route, json=data) + response = await assert_db_not_changed(async_client.post(collection_route, json=data), self.collection_name) assert response.status_code == 422 async def test_invalid_uncollapsible_geometry(self, fake, async_client, collection_route): @@ -347,7 +162,7 @@ async def test_invalid_uncollapsible_geometry(self, fake, async_client, collecti **json.loads(fake.data_request().model_dump_json()), "geometry": json.loads(fake.uncollapsible_geojson().model_dump_json()), } - response = await async_client.post(collection_route, json=data) + response = await assert_db_not_changed(async_client.post(collection_route, json=data), self.collection_name) assert response.status_code == 422 @@ -356,12 +171,13 @@ class TestPostUser(_TestPost, _TestUser): ... class TestPostAdmin(_TestPost, _TestAdmin): @pytest.fixture - def collection_route(self, data_requests): - return f"/v1/admin/data-requests/?user={data_requests[0]['user']}" + def collection_route(self, records): + return f"/v1/admin/data-requests/?user={records[0]['user']}" class _TestUpdate: request_path_type = "member" + collection_name = "data-request" @pytest.fixture(autouse=True) async def loaded_data(self, fake): @@ -372,13 +188,17 @@ async def loaded_data(self, fake): return model @pytest.fixture - async def data_requests(self, loaded_data): + async def records(self, loaded_data): return [{"_id": loaded_data["id"], **loaded_data}] -class _TestPatch(_TestUpdate): +class _TestPatch(_TestUpdate, UpdateTest): request_method = "patch" + @pytest.fixture + def valid_update(self): + return {"title": "test123"} + async def test_valid(self, loaded_data, async_client, fake, member_route): title = fake.sentence() update = {"title": title} @@ -396,19 +216,6 @@ async def test_valid_multiple(self, loaded_data, async_client, fake, member_rout loaded_data.update(update) compare_no_timestamps(loaded_data, response.json()) - async def test_update_nothing(self, loaded_data, async_client, member_route): - response = await async_client.patch(member_route, json={}) - assert response.status_code == 200 - compare_no_timestamps(loaded_data, response.json()) - - async def test_no_id_update(self, loaded_data, async_client, member_route): - update = {"id": str(bson.ObjectId())} - response = await async_client.patch(member_route, json=update) - assert response.status_code == 200 - assert response.json()["id"] == loaded_data["id"] - assert response.json()["id"] != update["id"] - compare_no_timestamps(loaded_data, response.json()) - async def test_invalid_unset_value(self, async_client, member_route): response = await async_client.patch(member_route, json={"title": None}) assert response.status_code == 422 @@ -424,31 +231,6 @@ async def test_invalid_uncollapsible_geometry(self, fake, async_client, member_r ) assert response.status_code == 422 - async def test_bad_id(self, async_client, collection_route): - resp = await async_client.patch(f"{collection_route}/id-does-not-exist", json={}) - assert resp.status_code == 404, resp.json() - - async def test_created_in_response(self, fake, async_client, member_route): - title = fake.sentence() - update = {"title": title} - response = await async_client.patch(member_route, json=update) - assert response.status_code == 200 - assert response.json()["created"] - - async def test_updated_updated(self, loaded_data, fake, async_client, member_route): - title = fake.sentence() - update = {"title": title} - response = await async_client.patch(member_route, json=update) - assert response.status_code == 200 - assert loaded_data["updated"] != response.json()["updated"] - - @pytest.mark.parametrize("field", ["created", "updated"]) - async def test_no_updatable_timestamps(self, loaded_data, async_client, member_route, field): - new_date = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=10) - response = await async_client.patch(member_route, json={field: new_date.isoformat()}) - assert response.status_code == 200 - assert response.json()[field] != new_date.isoformat() - class TestPatchUser(_TestPatch, _TestUser): async def test_update_everything(self, loaded_data, async_client, fake, member_route): @@ -461,7 +243,9 @@ async def test_update_everything(self, loaded_data, async_client, fake, member_r async def test_no_update_user(self, loaded_data, async_client, member_route): new_user = loaded_data["user"] + "suffix" - response = await async_client.patch(member_route, json={"user": new_user}) + response = await assert_db_not_changed( + async_client.patch(member_route, json={"user": new_user}), self.collection_name + ) assert response.status_code == 403 @@ -484,6 +268,7 @@ class _TestDelete(_TestUpdate): request_method = "delete" async def test_exists(self, loaded_data, async_client, member_route): + response = await async_client.delete(member_route) assert response.status_code == 204 resp = await client.db.get_collection("data-request").find_one({"_id": bson.ObjectId(loaded_data["id"])}) @@ -491,14 +276,14 @@ async def test_exists(self, loaded_data, async_client, member_route): async def test_bad_id(self, async_client, member_route): route = "/" + "/".join(member_route.strip("/").split("/")) + "bad-id-suffix" - resp = await async_client.delete(route) + resp = await assert_db_not_changed(async_client.delete(route), self.collection_name) assert resp.status_code == 404 class TestDeleteUser(_TestDelete, _TestUser): async def test_bad_user(self, loaded_data, async_client, member_route): route = f"/v1/users/someotheruser/data-requests/{loaded_data['id']}" - response = await async_client.delete(route) + response = await assert_db_not_changed(async_client.delete(route), self.collection_name) assert response.status_code == 404 diff --git a/test/integration/versions/v1/survey/conftest.py b/test/integration/versions/v1/survey/conftest.py new file mode 100644 index 0000000..da6681d --- /dev/null +++ b/test/integration/versions/v1/survey/conftest.py @@ -0,0 +1,9 @@ +import pytest +from faker import Faker + + +@pytest.fixture(scope="session") +def fake(faker_providers): + fake_ = Faker() + fake_.add_provider(faker_providers["SurveyProvider"]) + return fake_ diff --git a/test/integration/versions/v1/survey/test_routes.py b/test/integration/versions/v1/survey/test_routes.py new file mode 100644 index 0000000..4819f33 --- /dev/null +++ b/test/integration/versions/v1/survey/test_routes.py @@ -0,0 +1,675 @@ +import inspect +import json +from contextlib import asynccontextmanager + +import bson +import pymongo +import pytest + +from marble_api.database import client +from marble_api.versions.v1.survey.models import Survey, SurveyPublic +from marble_api.versions.v1.survey.routes import get_survey_responses, get_surveys + +from .....integration.utils._test_auth import AdminAuthTest, UserAuthTest +from .....integration.utils._test_routes import ( + GetManyTest, + PreloadData, + UpdateTest, + assert_db_not_changed, + compare_no_timestamps, +) +from .....unit.utils.test_models import _create_model_func +from .....unit.utils.test_routes import assert_approx_now + +pytestmark = pytest.mark.anyio + + +@asynccontextmanager +async def _set_user_visible_false(record_id, collection): + id_ = bson.ObjectId(record_id) + resp = await client.db.get_collection(collection).find_one_and_update( + {"_id": id_}, + {"$set": {"user_visible": False}}, + return_document=pymongo.ReturnDocument.AFTER, + ) + assert not resp["user_visible"] + yield + resp = await client.db.get_collection(collection).find_one_and_update( + {"_id": id_}, + {"$set": {"user_visible": True}}, + return_document=pymongo.ReturnDocument.AFTER, + ) + assert resp["user_visible"] + + +class _TestGetSurvey(PreloadData): + request_method = "get" + collection_name = "survey" + + @pytest.fixture(scope="class") + @classmethod + def fake_class(cls, fake): + return _create_model_func(fake.survey, user_visible=True) + + +class _TestGetOneSurvey(_TestGetSurvey): + request_path_type = "member" + + @pytest.fixture + async def set_user_visible_false(self, records): + async with _set_user_visible_false(records[0]["_id"], self.collection_name): + yield + + async def test_get(self, async_client, records, member_route): + resp = await assert_db_not_changed(async_client.get(member_route), self.collection_name) + assert resp.status_code == 200 + assert SurveyPublic(**records[0]).model_dump() == SurveyPublic(**resp.json()).model_dump() + + async def test_bad_id(self, async_client, member_route): + invalid_route = "/".join(member_route.split("/")[:-1] + ["some-bad-id"]) + resp = await assert_db_not_changed(async_client.get(invalid_route), self.collection_name) + assert resp.status_code == 404 + + +class _TestGetManySurvey(_TestGetSurvey, GetManyTest): + request_path_type = "collection" + default_link_limit = inspect.signature(get_surveys).parameters["limit"].default + n_records = default_link_limit * 2 + 2 + n_records_return_count: int + records_key = "surveys" + sort_keys = ("id", "created", "updated", "user_visible") + + @pytest.fixture(autouse=True, scope="class") + @classmethod + async def set_half_user_visible_false(cls, load_data, records): + for record in records[: len(records) // 2]: + id_ = bson.ObjectId(record["_id"]) + assert await client.db.get_collection(cls.collection_name).find({}).to_list() + resp = await client.db.get_collection(cls.collection_name).find_one_and_update( + {"_id": id_}, + {"$set": {"user_visible": False}}, + return_document=pymongo.ReturnDocument.AFTER, + ) + assert not resp["user_visible"] + yield + + +class _TestPostSurvey: + request_method = "post" + request_path_type = "collection" + collection_name = "survey" + + +class _TestUpdateSurvey: + request_path_type = "member" + collection_name = "survey" + + @pytest.fixture(autouse=True) + async def loaded_data(self, fake): + model = json.loads(fake.survey(user_visible=False).model_dump_json()) + resp = await client.db.get_collection("survey").insert_one(model) + model.pop("_id") + model["id"] = str(resp.inserted_id) + return model + + @pytest.fixture + async def records(self, loaded_data): + return [{"_id": loaded_data["id"], **loaded_data}] + + async def check_loaded_data_unchanged_in_db(self, loaded_data): + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + assert Survey(**resp).model_dump() == Survey(**loaded_data).model_dump() + + +class _TestPatchSurvey(_TestUpdateSurvey, UpdateTest): + request_method = "patch" + + @pytest.fixture + def valid_update(self): + return {"user_visible": False} + + async def test_update_user_visible(self, async_client, member_route, loaded_data): + resp = await async_client.patch(member_route, json={"user_visible": True}) + assert resp.status_code == 200 + assert resp.json()["user_visible"] + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + assert resp["user_visible"] + + async def test_update_questions_exist(self, fake, async_client, member_route, loaded_data): + questions = [json.loads(q.model_dump_json()) for q in fake.survey().questions] + resp = await async_client.patch(member_route, json={"questions": questions}) + assert resp.status_code == 200 + assert resp.json()["questions"] == questions + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + for question in resp["questions"]: + if question["pattern"]: + question["pattern"] = question["pattern"].pattern + assert resp["questions"] == questions + + async def test_no_update_questions_if_user_visible(self, async_client, member_route, fake, loaded_data): + resp = await async_client.patch(member_route, json={"user_visible": True}) + assert resp.status_code == 200 + resp2 = await async_client.patch( + member_route, json={"questions": [json.loads(q.model_dump_json()) for q in fake.survey().questions]} + ) + assert resp2.status_code == 403 + await self.check_loaded_data_unchanged_in_db(resp.json()) + + async def test_update_user_visible_if_user_visible(self, async_client, member_route, loaded_data): + resp = await async_client.patch(member_route, json={"user_visible": True}) + assert resp.status_code == 200 + resp = await async_client.patch(member_route, json={"user_visible": False}) + assert resp.status_code == 200 + assert not resp.json()["user_visible"] + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + assert not resp["user_visible"] + + async def test_no_update_questions_if_responses_exist(self, fake, loaded_data, async_client, member_route): + model = json.loads(fake.response(for_survey=Survey(**loaded_data)).model_dump_json()) + resp = await client.db.get_collection("survey-response").insert_one(model) + resp = await assert_db_not_changed( + async_client.patch( + member_route, json={"questions": [json.loads(q.model_dump_json()) for q in fake.survey().questions]} + ), + self.collection_name, + ) + assert resp.status_code == 403 + # check that the value in the database was not updated + await self.check_loaded_data_unchanged_in_db(loaded_data) + + async def test_update_user_visible_if_responses_exist(self, fake, loaded_data, async_client, member_route): + model = json.loads(fake.response(for_survey=Survey(**loaded_data)).model_dump_json()) + resp = await client.db.get_collection("survey-response").insert_one(model) + resp = await async_client.patch(member_route, json={"user_visible": True}) + assert resp.status_code == 200 + assert resp.json()["user_visible"] + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + assert resp["user_visible"] + + async def test_patch_invalid_update(self, fake, loaded_data, async_client, member_route): + invalid_question = json.loads(fake.text_question().model_dump_json()) + invalid_question["min_length"] = 7 + invalid_question["max_length"] = 5 + resp = await assert_db_not_changed( + async_client.patch(member_route, json={"questions": [invalid_question]}), self.collection_name + ) + assert resp.status_code == 422 + # check that the value in the database was not updated (rolled back!) + await self.check_loaded_data_unchanged_in_db(loaded_data) + + +class _TestDeleteSurvey(_TestUpdateSurvey): + request_method = "delete" + + async def test_no_delete_if_user_visible(self, async_client, member_route, loaded_data): + resp = await async_client.patch(member_route, json={"user_visible": True}) + assert resp.status_code == 200 + resp = await assert_db_not_changed( + async_client.delete( + member_route, + ), + self.collection_name, + ) + assert resp.status_code == 403 + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + assert resp is not None + + async def test_no_delete_if_responses_exist(self, fake, loaded_data, async_client, member_route): + model = json.loads(fake.response(for_survey=Survey(**loaded_data)).model_dump_json()) + resp = await client.db.get_collection("survey-response").insert_one(model) + resp = await assert_db_not_changed(async_client.delete(member_route), self.collection_name) + assert resp.status_code == 403 + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + assert resp is not None + + async def test_delete_success(self, loaded_data, async_client, member_route): + resp = await async_client.delete(member_route) + assert resp.status_code == 204 + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + assert resp is None + + +class _TestSurveyUser(UserAuthTest): + @pytest.fixture + def user(self): + return "test-user" + + @pytest.fixture + def records(self): + return [{"_id": "something"}] + + @pytest.fixture + def member_route(self, records, user): + return f"/v1/users/{user}/surveys/{records[0].get('_id', 'should not get here')}" + + @pytest.fixture + def collection_route(self, user): + return f"/v1/users/{user}/surveys/" + + +class _TestSurveyAdmin(AdminAuthTest): + @pytest.fixture + def member_route(self, records): + return f"/v1/admin/surveys/{records[0].get('_id', 'should not get here')}" + + @pytest.fixture + def collection_route(self): + return "/v1/admin/surveys/" + + +class TestPostSurveyAdmin(_TestPostSurvey, _TestSurveyAdmin): + @pytest.fixture + def records(self): + return [{"user": "user1", "_id": "something"}] + + async def test_valid(self, fake, async_client, collection_route): + data = fake.survey().model_dump_json() + response = await async_client.post(collection_route, json=json.loads(data)) + assert response.status_code == 200 + response_data = response.json() + assert (id_ := response_data.pop("id", None)) + bson.ObjectId(id_) # check that the id is a valid object id + compare_no_timestamps(json.loads(data), response_data) + + async def test_invalid(self, fake, async_client, collection_route): + data = json.loads(fake.survey().model_dump_json()) + data["questions"][0]["text"] = None + response = await assert_db_not_changed(async_client.post(collection_route, json=data), self.collection_name) + assert response.status_code == 422 + + +class TestPatchSurveyAdmin(_TestPatchSurvey, _TestSurveyAdmin): ... + + +class TestDeleteSurveyAdmin(_TestDeleteSurvey, _TestSurveyAdmin): ... + + +@pytest.mark.no_db_cleanup +class TestGetSurveyUser(_TestGetOneSurvey, _TestSurveyUser): + async def test_get_no_user_visible(self, async_client, set_user_visible_false, member_route): + resp = await assert_db_not_changed(async_client.get(member_route), self.collection_name) + assert resp.status_code == 404 + + +@pytest.mark.no_db_cleanup +class TestGetSurveyAdmin(_TestGetOneSurvey, _TestSurveyAdmin): + async def test_get_no_user_visible(self, async_client, records, set_user_visible_false, member_route): + resp = await assert_db_not_changed(async_client.get(member_route), self.collection_name) + assert resp.status_code == 200 + expected_record = {**records[0], "user_visible": False} + assert SurveyPublic(**expected_record).model_dump() == SurveyPublic(**resp.json()).model_dump() + + +@pytest.mark.no_db_cleanup +class TestGetSurveysUser(_TestGetManySurvey, _TestSurveyUser): + n_records_return_count = _TestGetManySurvey.n_records // 2 + + async def test_get_user_visible_only(self, async_client, collection_route): + data = await assert_db_not_changed(self.get_all_data(async_client, collection_route), self.collection_name) + assert all(survey["user_visible"] for survey in data) + + +@pytest.mark.no_db_cleanup +class TestGetSurveysAdmin(_TestGetManySurvey, _TestSurveyAdmin): + n_records_return_count = _TestGetManySurvey.n_records + + async def test_visible_and_hidden(self, async_client, collection_route): + data = await assert_db_not_changed(self.get_all_data(async_client, collection_route), self.collection_name) + assert set(survey["user_visible"] for survey in data) == {True, False} + + +class TestPatchSurveyQuestionAdmin(_TestUpdateSurvey, _TestSurveyAdmin): + request_method = "patch" + + @pytest.fixture + def member_route(self, records): + return f"/v1/admin/surveys/{records[0].get('_id', 'should not get here')}/questions/0" + + async def test_no_patch_if_user_visible(self, async_client, member_route, loaded_data, fake): + resp = await async_client.patch(f"/v1/admin/surveys/{loaded_data['id']}", json={"user_visible": True}) + assert resp.status_code == 200 + resp2 = await assert_db_not_changed( + async_client.patch(member_route, json=json.loads(fake.question().model_dump_json())), self.collection_name + ) + assert resp2.status_code == 403 + await self.check_loaded_data_unchanged_in_db(resp.json()) + + async def test_no_patch_if_responses_exist(self, fake, loaded_data, async_client, member_route): + model = json.loads(fake.response(for_survey=Survey(**loaded_data)).model_dump_json()) + resp = await client.db.get_collection("survey-response").insert_one(model) + resp = await assert_db_not_changed( + async_client.patch(member_route, json=json.loads(fake.question().model_dump_json())), self.collection_name + ) + assert resp.status_code == 403 + await self.check_loaded_data_unchanged_in_db(loaded_data) + + async def test_patch_index_out_of_bounds(self, fake, loaded_data, async_client, member_route): + member_route = member_route[:-1] + str(len(loaded_data["questions"])) + question = json.loads(fake.question().model_dump_json()) + resp = await async_client.patch(member_route, json=question) + assert resp.status_code == 200 + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + expected_survey = Survey(**{**loaded_data, "questions": loaded_data["questions"] + [question]}) + assert resp["updated"] > expected_survey.updated + assert Survey(**resp).model_dump(exclude=["updated"]) == expected_survey.model_dump(exclude=["updated"]) + + async def test_patch_success(self, loaded_data, async_client, member_route, fake): + question = json.loads(fake.question().model_dump_json()) + resp = await async_client.patch(member_route, json=question) + assert resp.status_code == 200 + resp = await client.db.get_collection("survey").find_one({"_id": bson.ObjectId(loaded_data["id"])}) + expected_survey = Survey(**{**loaded_data, "questions": [question] + loaded_data["questions"][1:]}) + assert resp["updated"] > expected_survey.updated + assert Survey(**resp).model_dump(exclude=["updated"]) == expected_survey.model_dump(exclude=["updated"]) + + +class _TestSurveyResponseUser(UserAuthTest): + @pytest.fixture + def user(self, records): + return records[0]["user"] + + @pytest.fixture + def records(self): + return [{"user": "user1", "survey_id": "something"}] + + @pytest.fixture + def member_route(self, records, user): + return f"/v1/users/{user}/surveys/{records[0]['survey_id']}/response" + + @pytest.fixture + def collection_route(self, member_route): + return member_route # only one response per user per survey allowed so there is no distinction + + async def test_survey_not_visible(self, records, make_request): + async with _set_user_visible_false(records[0]["survey_id"], "survey"): + resp = await assert_db_not_changed(make_request, self.collection_name) + assert resp.status_code == 404 + + +class _TestSurveyResponseAdmin(AdminAuthTest): + @pytest.fixture + def member_route(self, records): + return f"/v1/admin/survey-responses/{records[0]['_id']}" + + @pytest.fixture + def collection_route(self): + return "/v1/admin/survey-responses/" + + +class _TestGetSurveyResponse(PreloadData): + request_method = "get" + collection_name = "survey-response" + + @pytest.fixture(scope="class") + @classmethod + def fake_class(cls, fake): + return fake.response + + @pytest.fixture(scope="class", autouse=True) + @classmethod + async def load_data(cls, fake): + surveys = [fake.survey(user_visible=True) for _ in range(cls.n_records)] + survey_ids = await client.db.get_collection("survey").insert_many( + [json.loads(survey.model_dump_json()) for survey in surveys] + ) + for i, id_ in enumerate(survey_ids.inserted_ids): + surveys[i].id = id_ + responses = [fake.response(user="user1", for_survey=surveys[i]) for i in range(cls.n_records // 2)] + [ + fake.response(user="user2", for_survey=surveys[i]) for i in range(cls.n_records // 2, cls.n_records) + ] + await client.db.get_collection(cls.collection_name).insert_many( + [json.loads(resp.model_dump_json()) for resp in responses] + ) + + +class _TestGetOneSurveyResponse(_TestGetSurveyResponse): + request_path_type = "member" + + +class _TestGetManySurveyResponse(_TestGetSurveyResponse, GetManyTest): + request_path_type = "collection" + default_link_limit = inspect.signature(get_survey_responses).parameters["limit"].default + n_records = default_link_limit * 2 + 2 + n_records_return_count = n_records + records_key = "survey_responses" + sort_keys = ("id", "created", "updated", "survey_id") + + +class _TestUpdatePostSurveyResponse: + collection_name = "survey-response" + + @pytest.fixture(autouse=True) + async def loaded_survey(self, fake, faker_providers): + choices = {"a": "a", "b": "b", "c": "c", "d": "d", "e": "e"} + survey = fake.survey( + user_visible=True, + questions=[ + fake.text_question(required=False, min_length=1, max_length=10, pattern=None), + fake.text_question( + required=True, + min_length=1, + max_length=10, + pattern=faker_providers["SurveyProvider"].example_pattern, + ), + fake.choice_question(required=False, min_choices=2, max_choices=4, choices=choices, allow_others=False), + fake.choice_question( + required=True, + min_choices=2, + max_choices=4, + allow_others=True, + choices=choices, + min_length=1, + max_length=10, + pattern=faker_providers["SurveyProvider"].example_pattern, + ), + ], + ) + resp = await client.db.get_collection("survey").insert_one(json.loads(survey.model_dump_json())) + survey.id = resp.inserted_id + return survey + + +class _TestUpdateSurveyResponse(_TestUpdatePostSurveyResponse): + @pytest.fixture(autouse=True) + async def loaded_data(self, fake, loaded_survey): + response_data = json.loads(fake.response(user="user1", for_survey=loaded_survey).model_dump_json()) + resp = await client.db.get_collection("survey-response").insert_one(response_data) + response_data.pop("_id") + response_data["id"] = str(resp.inserted_id) + return response_data + + @pytest.fixture + async def records(self, loaded_data): + return [{"_id": loaded_data["id"], **loaded_data}] + + +class _TestPutSurveyResponse(_TestUpdateSurveyResponse, UpdateTest): + request_method = "put" + request_path_type = "member" + + @pytest.fixture + def valid_update(self, fake, loaded_survey): + valid = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + return {"answers": valid["answers"]} + + async def test_bad_id(self, client_method, collection_route, records): + route = collection_route.replace(records[0]["survey_id"], "id-does-not-exist") + resp = await assert_db_not_changed(client_method(route, json={}), self.collection_name) + assert resp.status_code == 404, resp.url + + +class _TestPostSurveyResponse(_TestUpdatePostSurveyResponse): + request_method = "post" + request_path_type = "collection" + + @pytest.fixture + async def records(self, loaded_survey): + return [{"_id": bson.ObjectId(), "user": "user1", "survey_id": loaded_survey.id}] + + +class _TestDeleteSurveyResponse(_TestUpdateSurveyResponse): + request_method = "delete" + request_path_type = "member" + + +class TestPostSurveyResponseUser(_TestPostSurveyResponse, _TestSurveyResponseUser): + async def test_response_not_valid(self, loaded_survey, fake, async_client, member_route): + response = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + response["answers"] = [] + resp = await assert_db_not_changed(async_client.post(member_route, json=response), self.collection_name) + assert resp.status_code == 422 + + async def test_response_valid(self, loaded_survey, fake, async_client, member_route, user): + data = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + resp = await async_client.post(member_route, json=data) + assert resp.status_code == 200 + response_data = resp.json() + assert (id_ := response_data.pop("id", None)) + data["user"] = user # check that the user from the path is set + id_ = bson.ObjectId(id_) # check that the id is a valid object id + compare_no_timestamps(data, response_data) + db_value = await client.db.get_collection("survey-response").find_one({"_id": id_}) + assert db_value["answers"] == response_data["answers"] + assert_approx_now(db_value["updated"]) + + async def test_multiple_valid_responses(self, loaded_survey, fake, async_client, member_route): + data = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + resp = await async_client.post(member_route, json=data) + assert resp.status_code == 200 + data2 = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + resp = await assert_db_not_changed(async_client.post(member_route, json=data2), self.collection_name) + assert resp.status_code == 409 + + +class TestPutSurveyResponseUser(_TestPutSurveyResponse, _TestSurveyResponseUser): + async def test_response_not_valid(self, loaded_survey, fake, async_client, member_route, records): + response = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + response["answers"] = [] + resp = await assert_db_not_changed(async_client.put(member_route, json=response), self.collection_name) + assert resp.status_code == 422 + + async def test_response_update_survey_id(self, loaded_survey, fake, async_client, member_route, records): + resp = await client.db.get_collection("survey").insert_one(json.loads(loaded_survey.model_dump_json())) + new_id = resp.inserted_id + response = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + response["survey_id"] = str(new_id) + resp = await assert_db_not_changed(async_client.put(member_route, json=response), self.collection_name) + assert resp.status_code == 403 + + async def test_response_valid(self, loaded_survey, fake, async_client, member_route): + data = json.loads(fake.response(for_survey=loaded_survey).model_dump_json()) + resp = await async_client.put(member_route, json={"answers": data["answers"]}) + assert resp.status_code == 200 + response_data = resp.json() + assert (id_ := response_data.pop("id", None)) + id_ = bson.ObjectId(id_) # check that the id is a valid object id + db_value = await client.db.get_collection("survey-response").find_one({"_id": id_}) + assert db_value["answers"] == response_data["answers"] == data["answers"] + assert_approx_now(db_value["updated"]) + + +class TestPatchSurveyResponseUser(_TestUpdateSurveyResponse, _TestSurveyResponseUser): + request_method = "patch" + request_path_type = "member" + + @pytest.fixture + def member_route(self, records): + return f"/v1/users/{records[0]['user']}/surveys/{records[0]['survey_id']}/response/0" + + async def test_answer_not_valid(self, loaded_survey, async_client, member_route, records): + data = "a" * (loaded_survey.questions[0].max_length + 1) + resp = await assert_db_not_changed(async_client.patch(member_route, json=data), self.collection_name) + assert resp.status_code == 422 + + async def test_answer_index_out_of_bounds(self, loaded_survey, async_client, member_route, records): + resp = await assert_db_not_changed( + async_client.patch(member_route[:-1] + str(len(loaded_survey.questions)), json="a"), self.collection_name + ) + assert resp.status_code == 404 + + async def test_answer_valid(self, async_client, member_route, records): + if records[0]["answers"][0] is None: + answer = "aa" + resp = await async_client.patch(member_route, json=answer) + else: + answer = None + resp = await async_client.patch(member_route) + assert resp.status_code == 200, resp.json() + resp = await client.db.get_collection("survey-response").find_one({"_id": bson.ObjectId(records[0]["id"])}) + assert resp["answers"][0] == answer + + +@pytest.mark.no_db_cleanup +class TestGetSurveyResponseUser(_TestGetOneSurveyResponse, _TestSurveyResponseUser): + async def test_survey_visible(self, make_request): + resp = await make_request + assert resp.status_code == 200 + response_data = resp.json() + assert (id_ := response_data.get("id")) + id_ = bson.ObjectId(id_) # check that the id is a valid object id + db_value = await client.db.get_collection("survey-response").find_one({"_id": id_}) + db_value["id"] = str(db_value.pop("_id")) + compare_no_timestamps(db_value, response_data) + + +@pytest.mark.no_db_cleanup +class TestGetSurveyResponseAdmin(_TestGetOneSurveyResponse, _TestSurveyResponseAdmin): + @pytest.mark.parametrize("visible", [True, False], ids=["visible", "not_visible"]) + async def test_survey_visible(self, make_request, visible, records): + if visible: + resp = await make_request + else: + async with _set_user_visible_false(records[0]["survey_id"], "survey"): + resp = await make_request + assert resp.status_code == 200 + response_data = resp.json() + assert (id_ := response_data.get("id")) + id_ = bson.ObjectId(id_) # check that the id is a valid object id + db_value = await client.db.get_collection("survey-response").find_one({"_id": id_}) + db_value["id"] = str(db_value.pop("_id")) + compare_no_timestamps(db_value, response_data) + + +class TestDeleteSurveyResponseAdmin(_TestDeleteSurveyResponse, _TestSurveyResponseAdmin): + @pytest.mark.parametrize("visible", [True, False], ids=["visible", "not_visible"]) + async def test_survey_visible(self, make_request, visible, records): + if visible: + resp = await make_request + else: + async with _set_user_visible_false(records[0]["survey_id"], "survey"): + resp = await make_request + assert resp.status_code == 204 + db_value = await client.db.get_collection("survey-response").find_one({}) + assert db_value is None + + +@pytest.mark.no_db_cleanup +class TestGetSurveyResponsesAdmin(_TestGetManySurveyResponse, _TestSurveyResponseAdmin): ... + + +@pytest.mark.no_db_cleanup +class TestInvalidRoutes(_TestGetSurveyResponse): + # subclass _TestGetSurveyResponse so that valid data is loaded to the db + @pytest.mark.parametrize( + "method_route", + ( + ("post", "/v1/users/user1/surveys/", 405), + ("patch", f"/v1/users/user1/surveys/{bson.ObjectId()}", 405), + ("patch", f"/v1/users/user1/surveys/{bson.ObjectId()}/questions/0", 404), + ("delete", f"/v1/users/user1/surveys/{bson.ObjectId()}", 405), + ("post", f"/v1/admin/surveys/{bson.ObjectId()}/response", 404), + ("put", f"/v1/admin/surveys/{bson.ObjectId()}/response", 404), + ("patch", f"/v1/admin/surveys/{bson.ObjectId()}/response/0", 404), + ("get", f"/v1/admin/surveys/{bson.ObjectId()}/response", 404), + ("get", f"/v1/users/user1/survey-responses/{bson.ObjectId()}", 404), + ("delete", f"/v1/users/user1/survey-responses/{bson.ObjectId()}", 404), + ("get", "/v1/users/user1/survey-responses/", 404), + ), + ids=lambda val: ", ".join([str(v) for v in val]), + ) + async def test_routes_that_are_not_allowed_or_do_not_exist(self, async_client, method_route): + method, route, status = method_route + resp = await assert_db_not_changed( + assert_db_not_changed(getattr(async_client, method)(route), "survey"), "survey-response" + ) + assert resp.status_code == status diff --git a/test/unit/database/test_database.py b/test/unit/database/test_database.py index 9128cf3..de730eb 100644 --- a/test/unit/database/test_database.py +++ b/test/unit/database/test_database.py @@ -1,4 +1,4 @@ -from marble_api.database import Client, client +from marble_api.database import Client, client, collection class TestClient: @@ -15,5 +15,23 @@ def test_db_from_uri(self): assert Client("mongodb://example.com/other-db").db.name == "other-db" +def test_collection(): + collection("test") + + +class TestCollection: + def test_collection_call(self): + collection("test")() + args_list = client.db.__getitem__.call_args_list + assert len(args_list) == 1 + assert args_list[0].args == ("test",) + + def test_collection_call_override(self): + collection("test")("test2") + args_list = client.db.__getitem__.call_args_list + assert len(args_list) == 1 + assert args_list[0].args == ("test2",) + + def test_client_singleton(): assert isinstance(client, Client) diff --git a/test/unit/utils/test_utils_geojson.py b/test/unit/utils/test_geojson.py similarity index 100% rename from test/unit/utils/test_utils_geojson.py rename to test/unit/utils/test_geojson.py diff --git a/test/unit/utils/test_models.py b/test/unit/utils/test_models.py new file mode 100644 index 0000000..f76fdc7 --- /dev/null +++ b/test/unit/utils/test_models.py @@ -0,0 +1,181 @@ +import datetime + +import bson +import pytest +from pydantic import ValidationError, field_validator +from pydantic_core import PydanticSerializationError + +from marble_api.utils.models import ( + MarbleBaseModel, + MarbleBaseModelPublic, + MarbleBaseModelUpdate, + MarbleUserModel, + MarbleUserModelPublic, + MarbleUserModelUpdate, + PartialModel, + object_id, +) + + +class TestPartial: + @pytest.fixture + def partial_class(self): + class PModel(PartialModel): + a: int + b: str = None + + @field_validator("b") + @classmethod + def not_none(cls, value): + assert value is not None + return value + + return PModel + + def test_nothing_required(self, partial_class): + assert partial_class().model_dump() == {"a": None, "b": None} + + def test_field_without_default_settable(self, partial_class): + assert partial_class(a=10).model_dump() == {"a": 10, "b": None} + + def test_field_with_default_settable(self, partial_class): + assert partial_class(b="other").model_dump() == {"a": None, "b": "other"} + + def test_validations_still_work(self, partial_class): + with pytest.raises(ValidationError): + partial_class(a="some string") + + def test_no_default_validation(self, partial_class): + with pytest.raises(ValidationError): + partial_class(b=None) + + +def _create_model_func(klass, **kwargs): + def _(unset=None, _klass=klass, _kwargs=kwargs, **kw): + kwargs_ = {k: v for k, v in {**_kwargs, **kw}.items() if k not in (unset or [])} + return _klass(**kwargs_) + + return _ + + +class TestObjectId: + def test_valid_id(self): + id_ = bson.ObjectId() + assert id_ == object_id(str(id_), Exception) + + def test_invalid_id(self): + with pytest.raises(Exception): + object_id("invalid string", Exception) + + def test_invalid_id_custom_exception(self): + class MyCustomException(Exception): ... + + with pytest.raises(MyCustomException): + object_id("invalid string", MyCustomException) + + def test_invalid_id_custom_message(self): + with pytest.raises(Exception) as e: + msg = "here is a message" + object_id("invalid string", Exception(msg)) + assert msg == str(e) + + +class TestMarbleBaseModel: + @pytest.fixture + def fake_class(self): + return _create_model_func(MarbleBaseModel, id=bson.ObjectId()) + + def test_id_not_dumped(self, fake_class): + assert "id" not in fake_class().model_dump() + + def test_fields_not_in_schema(self, fake_class): + schema_properties = fake_class().model_json_schema(mode="serialization")["properties"] + assert not {"id", "user", "updated"} & set(schema_properties) + + def test_updated_tz_aware(self, fake_class): + with pytest.raises(ValidationError): + fake_class().updated = datetime.datetime.now() + fake_class().updated = datetime.datetime.now(tz=datetime.timezone.utc) + + def test_extra_not_allowed(self, fake_class): + with pytest.raises(ValidationError): + fake_class().extra = 10 + + +class TestMarbleBaseModelUpdate: + @pytest.fixture + def fake_class(self): + return _create_model_func(MarbleBaseModelUpdate, id=bson.ObjectId()) + + def test_partial_model(self, fake_class): + assert issubclass(type(fake_class()), PartialModel) + + def test_extra_not_allowed(self, fake_class): + with pytest.raises(ValidationError): + fake_class().extra = 10 + + +class TestMarbleBaseModelPublic: + @pytest.fixture + def fake_class(self): + return _create_model_func( + MarbleBaseModelPublic, id=bson.ObjectId(), updated=datetime.datetime.now(tz=datetime.timezone.utc) + ) + + def test_id_dumped(self, fake_class): + assert "id" in fake_class().model_dump() + + def test_fields_in_schema(self, fake_class): + fields = fake_class().model_json_schema(mode="serialization")["properties"] + assert all(field in fields for field in ["id", "updated", "created"]) + + def test_allow_extras(self, fake_class): + instance = fake_class() + instance.extra = 10 + assert instance.model_dump()["extra"] == 10 + + def test_created_computed_field(self, fake_class): + instance = fake_class() + assert instance.created == bson.ObjectId(instance.id).generation_time + + +class _UserTests: + def test_user(self, fake_class): + instance = fake_class() + assert instance.model_dump()["user"] == instance.user + + +class TestMarbleUserModel(TestMarbleBaseModel, _UserTests): + @pytest.fixture + def fake_class(self): + return _create_model_func(MarbleUserModel, id=bson.ObjectId(), user="test-user") + + def test_user_set_when_serialized(self, fake_class): + with pytest.raises(PydanticSerializationError): + fake_class(unset=["user"]).model_dump() + + +class TestMarbleUserModelUpdate(TestMarbleBaseModelUpdate, _UserTests): + @pytest.fixture + def fake_class(self): + return _create_model_func(MarbleUserModelUpdate, user="test-user") + + +class TestMarbleUserModelPublic(TestMarbleBaseModelPublic, _UserTests): + @pytest.fixture + def fake_class(self): + return _create_model_func( + MarbleUserModelPublic, + id=bson.ObjectId(), + updated=datetime.datetime.now(tz=datetime.timezone.utc), + user="test-user", + ) + + def test_fields_in_schema(self, fake_class): + fields = fake_class().model_json_schema(mode="serialization")["properties"] + assert all(field in fields for field in ["id", "updated", "created", "user"]) + + @pytest.mark.parametrize("value", [None, ""]) + def test_user_field_required(self, value, fake_class): + with pytest.raises(ValidationError): + fake_class(user=value) diff --git a/test/unit/utils/test_routes.py b/test/unit/utils/test_routes.py new file mode 100644 index 0000000..b18dfd1 --- /dev/null +++ b/test/unit/utils/test_routes.py @@ -0,0 +1,264 @@ +import datetime +from unittest.mock import AsyncMock, Mock, patch + +import bson +import pytest +from fastapi import HTTPException, Response + +from marble_api.utils.models import MarbleBaseModel, MarbleBaseModelUpdate, MarbleUserModel, MarbleUserModelPublic +from marble_api.utils.routes import delete_record, get_record, get_records, patch_record, post_record + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def collection(): + coll = AsyncMock() + + # mock insert_one + insert_one_return = Mock() + insert_one_return.inserted_id = 123 + coll.insert_one.return_value = insert_one_return + + return_example = {"updated": datetime.datetime.now(tz=datetime.timezone.utc), "user": "test-user"} + + # mock find_one_and_update + coll.find_one_and_update.return_value = {"_id": "find_one_and_update success", **return_example} + + # mock find_one + coll.find_one.return_value = {"_id": "find_one success", **return_example} + + # mock delete_one + delete_one_return = Mock() + delete_one_return.deleted_count = 1 + coll.delete_one.return_value = delete_one_return + + return coll + + +def assert_approx_now(time, msg=""): + assert datetime.datetime.now(tz=datetime.timezone.utc) - time < datetime.timedelta(seconds=1) + + +class TestPostRecord: + async def test_no_user(self, collection): + model = MarbleBaseModel() + result = await post_record(collection, None, model) + assert collection.insert_one.await_count == 1 + assert collection.insert_one.await_args.args[0] == result == {"id": "123", "updated": model.updated} + + async def test_sets_updated(self, collection): + model = MarbleBaseModel() + result = await post_record(collection, None, model) + assert isinstance(model.updated, datetime.datetime) + # check that the updated date is basically now + assert_approx_now(model.updated) + assert collection.insert_one.await_args.args[0]["updated"] == result["updated"] == model.updated + + async def test_with_user(self, collection): + model = MarbleUserModel(user="test-user") + result = await post_record(collection, "test-user", model) + assert collection.insert_one.await_count == 1 + assert ( + collection.insert_one.await_args.args[0] + == result + == {"id": "123", "updated": model.updated, "user": "test-user"} + ) + + async def test_with_updated_user(self, collection): + model = MarbleUserModel(user="test-user") + result = await post_record(collection, "other-user", model) + assert collection.insert_one.await_count == 1 + assert ( + collection.insert_one.await_args.args[0] + == result + == {"id": "123", "updated": model.updated, "user": "other-user"} + ) + + +class ModelUpdateExample(MarbleBaseModelUpdate): + f: str + + +def _set_unset_ids(prefix): + def _(val, _prefix=prefix): + return _prefix + ("_set" if val else "_unset") + + return _ + + +class TestPatchRecord: + def assert_update_matches(self, update_arg, expected_update): + assert list(update_arg.keys()) == ["$set"] + assert set(update_arg["$set"]) == set(expected_update) + for key in update_arg["$set"]: + if key == "updated": + assert_approx_now(update_arg["$set"][key]) + else: + assert update_arg["$set"][key] == expected_update[key] + + @pytest.mark.parametrize("id_set", [True, False], ids=_set_unset_ids("id")) + @pytest.mark.parametrize("user_set", [True, False], ids=_set_unset_ids("user")) + @pytest.mark.parametrize("additional_selector_set", [True, False], ids=_set_unset_ids("additional_selector")) + async def test_selectors(self, collection, id_set, user_set, additional_selector_set): + id_ = bson.ObjectId() if id_set else None + user = "test-user" if user_set else None + additional_selector = {"other": "selector"} if additional_selector_set else None + expected_selector = { + k: v for k, v in {"_id": id_, "user": user, **(additional_selector or {})}.items() if v is not None + } + data = {"f": "test"} + result = await patch_record( + collection, + id_=id_, + user=user, + additional_selector=additional_selector, + data=data, + validation_class=MarbleUserModelPublic, + ) + assert result["_id"] == "find_one_and_update success" + assert collection.find_one_and_update.await_count == 1 + await_args = collection.find_one_and_update.await_args.args + assert await_args[0] == expected_selector + self.assert_update_matches(await_args[1], {**data, "updated": None}) # None is a placeholder for now check + + @pytest.mark.parametrize("user_set", [True, False], ids=_set_unset_ids("user")) + @pytest.mark.parametrize("allow_update_user", [True, False], ids=["allow_update", "disallow_update"]) + @pytest.mark.parametrize("user_update_set", [True, False], ids=_set_unset_ids("user_update")) + async def test_allow_update_user(self, collection, user_set, allow_update_user, user_update_set): + user = "test-user" if user_set else None + data = {"user": "other-user"} if user_update_set else {} + kwargs = dict( + collection=collection, + id_=None, + user=user, + data=data, + allow_update_user=allow_update_user, + validation_class=MarbleUserModelPublic, + ) + if not allow_update_user and user_update_set: + with pytest.raises(HTTPException) as e_info: + await patch_record(**kwargs) + assert e_info.value.status_code == 403 + else: + await patch_record(**kwargs) + + @pytest.mark.parametrize("model_data", [True, False], ids=["model_data", "py_data"]) + async def test_update_model_data(self, collection, model_data): + data = ModelUpdateExample(f="test") if model_data else {"f": "test"} + result = await patch_record(collection, id_=None, user=None, data=data, validation_class=MarbleUserModelPublic) + assert result["_id"] == "find_one_and_update success" + assert collection.find_one_and_update.await_count == 1 + await_args = collection.find_one_and_update.await_args.args + self.assert_update_matches(await_args[1], {"f": "test", "updated": None}) # None is a placeholder for now check + + async def test_no_update(self, collection): + result = await patch_record(collection, id_=None, user=None, data={}, validation_class=MarbleUserModelPublic) + assert result["_id"] == "find_one success" + assert len(collection.find_one.await_args.args) == 1 + + async def test_set_prefix(self, collection): + result = await patch_record( + collection, + id_=None, + user=None, + data={"f": "test"}, + set_prefix="prefix.", + validation_class=MarbleUserModelPublic, + ) + assert result["_id"] == "find_one_and_update success" + assert collection.find_one_and_update.await_count == 1 + await_args = collection.find_one_and_update.await_args.args + self.assert_update_matches( + await_args[1], {"prefix.f": "test", "updated": None} + ) # None is a placeholder for now check + + @pytest.mark.parametrize("data_set", [True, False], ids=_set_unset_ids("data")) + async def test_not_found(self, collection, data_set): + data = {"f": "test"} if data_set else {} + if data_set: + collection.find_one_and_update.return_value = None + else: + collection.find_one.return_value = None + with pytest.raises(HTTPException) as e_info: + await patch_record(collection, id_=None, user=None, data=data, validation_class=MarbleUserModelPublic) + assert e_info.value.status_code == 404 + + +class TestGetRecord: + @pytest.mark.parametrize("id_set", [True, False], ids=_set_unset_ids("id")) + @pytest.mark.parametrize("user_set", [True, False], ids=_set_unset_ids("user")) + @pytest.mark.parametrize("additional_selector_set", [True, False], ids=_set_unset_ids("additional_selector")) + async def test_selectors(self, collection, id_set, user_set, additional_selector_set): + id_ = bson.ObjectId() if id_set else None + user = "test-user" if user_set else None + additional_selector = {"other": "selector"} if additional_selector_set else None + expected_selector = { + k: v for k, v in {"_id": id_, "user": user, **(additional_selector or {})}.items() if v is not None + } + result = await get_record(collection, id_=id_, user=user, additional_selector=additional_selector) + assert result["_id"] == "find_one success" + assert collection.find_one.await_count == 1 + await_args = collection.find_one.await_args.args + assert await_args[0] == expected_selector + + async def test_not_found(self, collection): + collection.find_one.return_value = None + with pytest.raises(HTTPException) as e_info: + await get_record(collection, id_=None, user=None) + assert e_info.value.status_code == 404 + + +class TestGetRecords: + @pytest.mark.parametrize("user_set", [True, False], ids=_set_unset_ids("user")) + @pytest.mark.parametrize("additional_selector_set", [True, False], ids=_set_unset_ids("additional_selector")) + @pytest.mark.parametrize("records_key_set", [True, False], ids=_set_unset_ids("records_key")) + async def test_selectors(self, collection, user_set, additional_selector_set, records_key_set): + user = "test-user" if user_set else None + additional_selector = {"other": "selector"} if additional_selector_set else None + other_kwargs = dict( + collection=collection, request="test", limit=20, sort_by="f", after="after", before="before", ascending=True + ) + expected_selector = {k: v for k, v in {"user": user, **(additional_selector or {})}.items() if v is not None} + # NOTE: the behaviour of _paginated_query is tested in integration tests because it's behaviour is very closely + # tied to a valid Request object and database connection. + with patch("marble_api.utils.routes._paginated_query", new_callable=AsyncMock) as mock: + mock.return_value = "paginated_query success", "links" + kwargs = {"user": user, "additional_selector": additional_selector, **other_kwargs} + if records_key_set: + kwargs["records_key"] = "new_key" + result = await get_records(**kwargs) + assert result == { + ("new_key" if records_key_set else "records"): mock.return_value[0], + "links": mock.return_value[1], + } + assert mock.await_count == 1 + assert mock.await_args.kwargs == {**other_kwargs, **expected_selector} + + +class TestDeleteRecord: + @pytest.mark.parametrize("id_set", [True, False], ids=_set_unset_ids("id")) + @pytest.mark.parametrize("user_set", [True, False], ids=_set_unset_ids("user")) + @pytest.mark.parametrize("additional_selector_set", [True, False], ids=_set_unset_ids("additional_selector")) + async def test_selectors(self, collection, id_set, user_set, additional_selector_set): + id_ = bson.ObjectId() if id_set else None + user = "test-user" if user_set else None + additional_selector = {"other": "selector"} if additional_selector_set else None + expected_selector = { + k: v for k, v in {"_id": id_, "user": user, **(additional_selector or {})}.items() if v is not None + } + result = await delete_record(collection, id_=id_, user=user, additional_selector=additional_selector) + assert isinstance(result, Response) + assert result.status_code == 204 + + assert collection.delete_one.await_count == 1 + await_args = collection.delete_one.await_args.args + assert await_args[0] == expected_selector + + async def test_not_found(self, collection): + delete_return_mock = Mock() + delete_return_mock.deleted_count.return_value = 0 + collection.delete_one.return_value = delete_return_mock + with pytest.raises(HTTPException) as e_info: + await delete_record(collection, id_=None, user=None) + assert e_info.value.status_code == 404 diff --git a/test/unit/utils/test_utils_models.py b/test/unit/utils/test_utils_models.py deleted file mode 100644 index c81d017..0000000 --- a/test/unit/utils/test_utils_models.py +++ /dev/null @@ -1,61 +0,0 @@ -import bson -import pytest -from pydantic import BaseModel, Field, ValidationError, field_validator - -from marble_api.utils.models import object_id, partial_model - - -class TestPartial: - @pytest.fixture - def partial_class(self): - @partial_model - class PModel(BaseModel): - a: int - b: str = Field(None, validate_default=True) - - @field_validator("b") - @classmethod - def not_none(cls, value): - assert value is not None - return value - - return PModel - - def test_nothing_required(self, partial_class): - assert partial_class().model_dump() == {"a": None, "b": None} - - def test_field_without_default_settable(self, partial_class): - assert partial_class(a=10).model_dump() == {"a": 10, "b": None} - - def test_field_with_default_settable(self, partial_class): - assert partial_class(b="other").model_dump() == {"a": None, "b": "other"} - - def test_validations_still_work(self, partial_class): - with pytest.raises(ValidationError): - partial_class(a="some string") - - def test_no_default_validation(self, partial_class): - with pytest.raises(ValidationError): - partial_class(b=None) - - -class TestObjectId: - def test_valid_id(self): - id_ = bson.ObjectId() - assert id_ == object_id(str(id_), Exception) - - def test_invalid_id(self): - with pytest.raises(Exception): - object_id("invalid string", Exception) - - def test_invalid_id_custom_exception(self): - class MyCustomException(Exception): ... - - with pytest.raises(MyCustomException): - object_id("invalid string", MyCustomException) - - def test_invalid_id_custom_message(self): - with pytest.raises(Exception) as e: - msg = "here is a message" - object_id("invalid string", Exception(msg)) - assert msg == str(e) diff --git a/test/unit/versions/v1/data_request/test_models.py b/test/unit/versions/v1/data_request/test_models.py index 2447430..0a19fc3 100644 --- a/test/unit/versions/v1/data_request/test_models.py +++ b/test/unit/versions/v1/data_request/test_models.py @@ -2,12 +2,15 @@ import pytest from pydantic import TypeAdapter, ValidationError -from pydantic_core import PydanticSerializationError from pystac import Item from marble_api.utils.geojson import collapse_geometries -from marble_api.utils.models import object_id -from marble_api.versions.v1.data_request.models import Author, DataRequestUpdate +from marble_api.versions.v1.data_request.models import Author + +# import like this to avoid re-running test classes that start with Test... +from .....unit.utils.test_models import TestMarbleUserModel as _TestMarbleUserModel +from .....unit.utils.test_models import TestMarbleUserModelPublic as _TestMarbleUserModelPublic +from .....unit.utils.test_models import TestMarbleUserModelUpdate as _TestMarbleUserModelUpdate class TestAuthor: @@ -27,14 +30,11 @@ def test_invalid_email(self): self.validator.validate_python(author) -class TestDataRequest: +class _TestDataRequest: @pytest.fixture def fake_class(self, fake): return fake.data_request - def test_id_dumped(self, fake_class): - assert "id" not in fake_class().model_dump() - @pytest.mark.parametrize("field", ["title", "description", "authors", "assets", "contact"]) def test_text_fields_not_empty(self, fake_class, field): with pytest.raises(ValidationError): @@ -56,13 +56,6 @@ def test_fields_not_nullable(self, fake_class, field): with pytest.raises(ValidationError): fake_class(**{field: None}) - @pytest.mark.parametrize("value", [None, ""]) - def test_user_field_present_when_serialized(self, fake_class, value): - model = fake_class() - model.user = value - with pytest.raises(PydanticSerializationError): - model.model_dump() - @pytest.mark.parametrize( "field", [ @@ -74,9 +67,6 @@ def test_fields_default_if_unset(self, fake_class, field): request = fake_class(unset=[field]) assert request.model_dump()[field] == type(request).model_fields[field].default - def test_id_is_str(self, fake_class): - assert isinstance(fake_class().id, str) - def test_temporal_sorted(self, fake_class): now = datetime.datetime.now(tz=datetime.timezone.utc) temporal = [now, now - datetime.timedelta(hours=1)] @@ -92,19 +82,14 @@ def test_uncollapsible_geometry(self, fake, fake_class): fake_class(geometry=fake.uncollapsible_geojson()) -class TestDataRequestPublic(TestDataRequest): +class TestDataRequest(_TestDataRequest, _TestMarbleUserModel): ... + + +class TestDataRequestPublic(_TestDataRequest, _TestMarbleUserModelPublic): @pytest.fixture def fake_class(self, fake): return fake.data_request_public - def test_id_dumped(self, fake_class): - assert "id" in fake_class().model_dump() - - def test_created(self, fake_class): - model = fake_class() - assert model.created - assert object_id(model.id, None).generation_time == model.created - class TestStacItem: def test_valid(self, fake_class): assert Item.from_dict(fake_class().stac_item) @@ -170,13 +155,7 @@ def test_id(self, fake_class): assert request.id == request.stac_item["id"] -class TestDataRequestUpdate(TestDataRequest): +class TestDataRequestUpdate(_TestDataRequest, _TestMarbleUserModelUpdate): @pytest.fixture def fake_class(self, fake): return fake.data_request_update - - def test_all_fields_optional(self): - DataRequestUpdate() - - def test_all_defaults_none(self): - assert all(field.default is None for field in DataRequestUpdate.model_fields.values()) diff --git a/test/unit/versions/v1/user_survey/conftest.py b/test/unit/versions/v1/user_survey/conftest.py new file mode 100644 index 0000000..67258b8 --- /dev/null +++ b/test/unit/versions/v1/user_survey/conftest.py @@ -0,0 +1,9 @@ +import pytest +from faker import Faker + + +@pytest.fixture(scope="session") +def fake(faker_providers) -> Faker: + fake_ = Faker() + fake_.add_provider(faker_providers["SurveyProvider"]) + return fake_ diff --git a/test/unit/versions/v1/user_survey/test_models.py b/test/unit/versions/v1/user_survey/test_models.py new file mode 100644 index 0000000..6d18e2b --- /dev/null +++ b/test/unit/versions/v1/user_survey/test_models.py @@ -0,0 +1,307 @@ +import bson +import pytest +from pydantic import ValidationError +from pydantic_core import PydanticSerializationError + +# import like this to avoid re-running test classes that start with Test... +from .....unit.utils.test_models import TestMarbleBaseModel as _TestMarbleBaseModel +from .....unit.utils.test_models import TestMarbleBaseModelPublic as _TestMarbleBaseModelPublic +from .....unit.utils.test_models import TestMarbleBaseModelUpdate as _TestMarbleBaseModelUpdate +from .....unit.utils.test_models import TestMarbleUserModel as _TestMarbleUserModel +from .....unit.utils.test_models import TestMarbleUserModelPublic as _TestMarbleUserModelPublic +from .....unit.utils.test_models import TestMarbleUserModelUpdate as _TestMarbleUserModelUpdate + + +class _TestQuestion: + def test_text_non_empty(self, fake_class): + fake_class() + with pytest.raises(ValidationError): + fake_class(text="") + + def test_validate_answer_required(self, fake_class): + instance = fake_class(required=True) + with pytest.raises(ValueError): + instance.validate_answer(None) + instance.required = False + instance.validate_answer(None) + + +class TestQuestion(_TestQuestion): + @pytest.fixture + def fake_class(self, fake): + return fake.question + + def test_field_required(self, fake_class): + with pytest.raises(ValidationError): + fake_class(unset=["text"]) + + def test_required_default(self, fake_class): + assert fake_class(unset=["required"]).required is False + + +class _TestQuestionUpdate: + def test_all_optional(self, fake_class): + fields = type(fake_class()).model_fields + fake_class(unset=list(fields)) + + +class TestTextQuestion(_TestQuestion): + @pytest.fixture + def fake_class(self, fake): + return fake.text_question + + def test_question_type_is_text(self, fake_class): + with pytest.raises(ValidationError): + fake_class(question_type="other") + fake_class(question_type="text") + + def test_min_max_length_bounds(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_length=0) + with pytest.raises(ValidationError): + fake_class(max_length=1001) + + def test_validate_length(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_length=8, max_length=7) + + def test_validate_answer(self, fake_class, fake): + instance = fake_class() + instance.validate_answer(fake.answer(for_question=instance)) + + @pytest.mark.parametrize("answer", [1, True, {"set"}, {"dict": 2}]) + def test_validate_answer_invalid_type(self, fake_class, fake, answer): + with pytest.raises(ValueError): + fake_class().validate_answer(answer) + + @pytest.mark.parametrize( + "length", range(3, 8), ids=lambda i: f"in_range[{i}]" if 4 <= i <= 6 else f"out_range[{i}]" + ) + def test_validate_answer_length(self, fake_class, length): + instance = fake_class(min_length=4, max_length=6) + if 4 <= length <= 6: + instance.validate_answer("a" * length) + else: + with pytest.raises(ValueError): + instance.validate_answer("a" * length) + + def test_pattern_none(self, fake_class, fake): + instance = fake_class(min_length=1, max_length=10, pattern=None) + instance.validate_answer(fake.pystr(min_chars=1, max_chars=10)) + + def test_pattern_set(self, fake_class): + instance = fake_class(min_length=1, max_length=10, pattern=r"^\d\daa$") + instance.validate_answer("24aa") + with pytest.raises(ValueError): + instance.validate_answer("bad answer") + with pytest.raises(ValueError): + instance.validate_answer("2aa") # close answer + + +class TestTextQuestionUpdate(TestTextQuestion, _TestQuestionUpdate): + @pytest.fixture + def fake_class(self, fake): + return fake.text_question_update + + def test_validate_length(self, fake_class): + # don't validate partial models + fake_class(min_length=8, max_length=7) + + +class TestChoiceQuestion(_TestQuestion): + @pytest.fixture + def fake_class(self, fake): + return fake.choice_question + + def test_question_type_is_choice(self, fake_class): + with pytest.raises(ValidationError): + fake_class(question_type="other") + fake_class(question_type="choice") + + def test_min_choices_positive(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_choices=0) + + def test_min_choices_not_nullable(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_choices=None) + + def test_max_choices_positive(self, fake_class): + with pytest.raises(ValidationError): + fake_class(max_choices=0) + + def test_max_choices_nullable(self, fake_class): + fake_class(max_choices=None) + + def test_choices_non_empty(self, fake_class): + with pytest.raises(ValidationError): + fake_class(choices={}) + + def test_validate_min_max_choices(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_choices=8, max_choices=7) + + def test_min_max_length_bounds(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_length=0) + with pytest.raises(ValidationError): + fake_class(max_length=1001) + + def test_validate_length(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_length=8, max_length=7) + + def test_min_choices_choices_available(self, fake_class): + with pytest.raises(ValidationError): + fake_class(min_choices=3, choices={"a": "a", "b": "b"}) + + def test_validate_answer(self, fake_class, fake): + instance = fake_class() + instance.validate_answer(fake.answer(for_question=instance)) + + @pytest.mark.parametrize("answer", ["a", 1, True, {"set"}, {"dict": 2}]) + def test_validate_answer_invalid_type(self, fake_class, fake, answer): + with pytest.raises(ValueError): + fake_class().validate_answer(answer) + + @pytest.mark.parametrize( + "length", range(3, 8), ids=lambda i: f"in_range[{i}]" if 4 <= i <= 6 else f"out_range[{i}]" + ) + def test_validate_choice_count(self, fake_class, length): + choices = {str(i): "a" for i in range(8)} + instance = fake_class(min_choices=4, max_choices=6, choices=choices) + if 4 <= length <= 6: + instance.validate_answer(list(choices)[:length]) + else: + with pytest.raises(ValueError): + instance.validate_answer(list(choices)[:length]) + + def test_pattern_none(self, fake_class, fake): + instance = fake_class( + min_choices=1, + max_choices=1, + min_length=1, + max_length=10, + pattern=None, + allow_others=True, + choices={"1": "a"}, + ) + # min_chars=2 guarantees that the pystr will not be one of the choices + instance.validate_answer([fake.pystr(min_chars=2, max_chars=10)]) + + def test_pattern_set(self, fake_class): + instance = fake_class( + min_choices=1, + max_choices=1, + min_length=1, + max_length=10, + allow_others=True, + pattern=r"^\d\daa$", + choices={"1": "a"}, + ) + instance.validate_answer(["24aa"]) + with pytest.raises(ValueError): + instance.validate_answer(["bad answer"]) + with pytest.raises(ValueError): + instance.validate_answer(["2aa"]) # close answer + + +class TestChoiceQuestionUpdate(TestChoiceQuestion, _TestQuestionUpdate): + @pytest.fixture + def fake_class(self, fake): + return fake.choice_question_update + + def test_validate_length(self, fake_class): + # don't validate partial models + fake_class(min_length=8, max_length=7) + + def test_validate_min_max_choices(self, fake_class): + # don't validate partial models + fake_class(min_choices=8, max_choices=7) + + def test_min_choices_choices_available(self, fake_class): + # don't validate partial models + fake_class(min_choices=3, choices={"a": "a", "b": "b"}) + + +class _TestSurvey: + def test_at_least_one_question(self, fake_class): + with pytest.raises(ValidationError): + fake_class(questions=[]) + + +class TestSurvey(_TestSurvey, _TestMarbleBaseModel): + @pytest.fixture + def fake_class(self, fake): + return fake.survey + + +class TestSurveyUpdate(_TestSurvey, _TestMarbleBaseModelUpdate): + @pytest.fixture + def fake_class(self, fake): + return fake.survey_update + + +class TestSurveyPublic(_TestSurvey, _TestMarbleBaseModelPublic): + @pytest.fixture + def fake_class(self, fake): + return fake.survey_public + + +class _TestSurveyResponse: + def test_validate_response_valid(self, fake_class, fake): + survey = fake.survey() + response = fake_class(for_survey=survey) + response.validate_response(survey) + + def test_validate_response_survey_no_id_match(self, fake_class, fake): + survey = fake.survey() + response = fake_class(for_survey=survey) + survey.id = bson.ObjectId() + with pytest.raises(ValueError): + response.validate_response(survey) + + def test_validate_response_wrong_number_answers(self, fake_class, fake): + survey = fake.survey(allow_others=True) + response = fake_class(for_survey=survey) + with pytest.raises(ValueError): + fake_class(survey_id=survey.id, answers=response.answers[:-1]).validate_response(survey) + with pytest.raises(ValueError): + fake_class(survey_id=survey.id, answers=response.answers + ["other"]).validate_response(survey) + + def test_validate_response_invalid_answers(self, fake_class, fake): + survey = fake.survey(allow_others=True, required=True) + + def _patch_validate_answer(*_a, **_kw): + raise ValueError() + + survey.questions[0]._validate_answer = _patch_validate_answer + response = fake_class(for_survey=survey) + with pytest.raises(ValueError): + response.validate_response(survey) + + +class TestSurveyResponse(_TestSurveyResponse, _TestMarbleUserModel): + @pytest.fixture + def fake_class(self, fake): + return fake.response + + def test_survey_id_set_when_serialized(self, fake_class): + with pytest.raises(PydanticSerializationError): + fake_class(unset=["survey_id"]).model_dump() + + +class TestSurveyResponseUpdate(_TestSurveyResponse, _TestMarbleUserModelUpdate): + @pytest.fixture + def fake_class(self, fake): + return fake.response_update + + +class TestSurveyResponsePublic(_TestSurveyResponse, _TestMarbleUserModelPublic): + @pytest.fixture + def fake_class(self, fake): + return fake.response_public + + def test_survey_id_required(self, fake_class): + with pytest.raises(ValidationError): + fake_class(unset=["survey_id"])