diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 3d7ad4c..9eede1c 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -27,4 +27,17 @@ jobs: run: pip install tox - run: tox env: - TOXENV: ruff \ No newline at end of file + TOXENV: ruff + + check-migrations: + name: Check for missing migrations + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install tox + - name: Check for missing migrations + run: tox -e check-migrations diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac17af9..67c6020 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,8 +3,45 @@ Changelog ========= -[0.4.1 (2025-12-03)] -==================== +[Unreleased] +============ + +New features +------------ + +* Replaced dataclasses with pydantic models for better validation. + +Bugfixes +-------- + +* Incorrect attributes where used in get_healthy() and get_objects() + (the bug was introduced in an earlier refactor in which these calls + were overlooked). +* Incorrect fields where used to check if API services are configured. + +Maintenance and refactoring +--------------------------- + +* Added check for missing migrations in CI. +* Cleaned up utility function get_object_type_choices() which had unused + parameter and made a useless check for None as result of instantiating + ObjectsAPIService. +* Removed bump-my-version for version upgrades. +* Improved test coverage for model fields, API clients, and utils. +* Improved code quality: + - Added/corrected type hints. + - Replaced magic number for cache timeout with named constant. + - Replaced old '.format()' syntax with modern f-strings. +* Improved error-handling for API clients. +* Renamed 'ObjectsClientConfiguration' to 'ObjectsAPIServiceConfiguration' + as well as the fields for the ZGW client configurations. This is lesss + confusing and captures the intent of the code better. +* Added 'default_auto_field' to app config and 'DEFAULT_AUTO_FIELD' to + testapp settings to silence warnings. + + +0.4.1 (2025-12-03) +================== * Added CI check to publishing workflow to ensure the changelog is ready for release (must contain new version and release date) diff --git a/objectsapiclient/__init__.py b/objectsapiclient/__init__.py index 92e424a..6c51d04 100644 --- a/objectsapiclient/__init__.py +++ b/objectsapiclient/__init__.py @@ -1 +1,17 @@ -default_app_config = "objectsapiclient.apps.ObjectsAPIClientConfig" +from objectsapiclient.dataclasses import ( + DataClassification, + Object, + ObjectRecord, + ObjectType, + ObjectTypeVersion, + UpdateFrequency, +) + +__all__ = [ + "DataClassification", + "UpdateFrequency", + "Object", + "ObjectRecord", + "ObjectType", + "ObjectTypeVersion", +] diff --git a/objectsapiclient/admin.py b/objectsapiclient/admin.py index 74cb347..d6fab34 100644 --- a/objectsapiclient/admin.py +++ b/objectsapiclient/admin.py @@ -1,21 +1,24 @@ from django.contrib import admin +from django.contrib.admin.templatetags.admin_list import _boolean_icon +from django.core.exceptions import ImproperlyConfigured from django.utils.html import format_html +from django.utils.safestring import SafeString from solo.admin import SingletonModelAdmin -from .client import Client -from .models import ObjectsClientConfiguration +from .models import ObjectsAPIServiceConfiguration +from .services import ObjectsAPIService -@admin.register(ObjectsClientConfiguration) -class ObjectsServiceConfigurationAdmin(SingletonModelAdmin): +@admin.register(ObjectsAPIServiceConfiguration) +class ObjectsAPIServiceConfigurationAdmin(SingletonModelAdmin): fieldsets = ( ( None, { "fields": ( - "objects_api_service_config", - "object_type_api_service_config", + "objects_api_client_config", + "objecttypes_api_client_config", "status", ) }, @@ -24,10 +27,10 @@ class ObjectsServiceConfigurationAdmin(SingletonModelAdmin): readonly_fields = ("status",) @admin.display - def status(self, obj): - from django.contrib.admin.templatetags.admin_list import _boolean_icon - - client = Client() - - healthy, message = client.is_healthy() - return format_html("{} {}", _boolean_icon(healthy), message) + def status(self, obj: ObjectsAPIServiceConfiguration) -> SafeString: + try: + service = ObjectsAPIService() + healthy, message = service.is_healthy() + return format_html("{} {}", _boolean_icon(healthy), message) + except ImproperlyConfigured as exc: + return format_html("{} {}", _boolean_icon(False), str(exc)) diff --git a/objectsapiclient/apps.py b/objectsapiclient/apps.py index 4e93f99..d925554 100644 --- a/objectsapiclient/apps.py +++ b/objectsapiclient/apps.py @@ -3,3 +3,4 @@ class ObjectsAPIClientConfig(AppConfig): name = "objectsapiclient" + default_auto_field = "django.db.models.AutoField" diff --git a/objectsapiclient/client.py b/objectsapiclient/client.py deleted file mode 100644 index 89dfdae..0000000 --- a/objectsapiclient/client.py +++ /dev/null @@ -1,92 +0,0 @@ -import logging -from typing import cast -from urllib.parse import urljoin - -from django.core.exceptions import ImproperlyConfigured - -from requests.exceptions import HTTPError -from zgw_consumers.api_models.base import factory -from zgw_consumers.client import build_client as build_zgw_client - -from .dataclasses import Object, ObjectType -from .models import ObjectsClientConfiguration - -logger = logging.getLogger(__name__) - - -class Client: - def __init__(self, config: ObjectsClientConfiguration | None = None): - self.config = cast( - ObjectsClientConfiguration, config or ObjectsClientConfiguration.get_solo() - ) - - if ( - not self.config.objects_api_service_config - or not self.config.object_type_api_service_config - ): - raise ImproperlyConfigured( - "ObjectsService cannot be instantiated without configurations for " - "Objects API and Objecttypes API" - ) - - self.objects = build_zgw_client(service=self.config.objects_api_service_config) - self.object_types = build_zgw_client( - service=self.config.object_type_api_service_config - ) - - def is_healthy(self) -> tuple[bool, str]: - try: - self.objects_api_client.request( - "head", - urljoin(base=self.objects_api_service_config.api_root, url="objects"), - ) - return True, "" - except HTTPError as exc: - logger.exception("Server did not return a valid response (%s)", exc) - return False, str(exc) - except Exception as exc: - logger.exception("Error making head request to objects api (%s)", exc) - return False, str(exc) - - def object_type_uuid_to_url(self, uuid): - return "{}objecttypes/{}/".format(self.object_types.base_url, uuid) - - def get_objects(self, object_type_uuid=None) -> list: - """ - Retrieve all available Objects from the Objects API. - Generally you'd want to filter the results to a single ObjectType UUID. - - :returns: Returns a list of Object dataclasses - """ - if object_type_uuid: - ot_url = self.object_type_uuid_to_url(object_type_uuid) - response = self.objects_api_client.request( - "get", - urljoin(base=self.objects_api_client.base_url, url="objects"), - params={"type": ot_url}, - ) - else: - response = self.objects_api_client.request( - "get", urljoin(base=self.objects_api_client.base_url, url="objects") - ) - - response.raise_for_status() - results = response.json().get("results") - - return factory(Object, results) if results else [] - - def get_object_types(self) -> list: - """ - Retrieve all available Object Types - - :returns: Returns a list of ObjectType dataclasses - """ - response = self.object_types.request( - method="get", - url=urljoin(self.object_types.base_url, "objecttypes"), - ) - - response.raise_for_status() - results = response.json().get("results") - - return factory(ObjectType, results) if results else [] diff --git a/objectsapiclient/dataclasses.py b/objectsapiclient/dataclasses.py index 3a8b915..777555c 100644 --- a/objectsapiclient/dataclasses.py +++ b/objectsapiclient/dataclasses.py @@ -1,57 +1,262 @@ -from dataclasses import dataclass - - -@dataclass -class ObjectRecord: - index: int - typeVersion: int - data: dict - geometry: dict - startAt: str - endAt: str - registrationAt: str - correctionFor: str - correctedBy: str - - -@dataclass -class Object: - url: str - uuid: str - type: str - record: list[ObjectRecord] - - -@dataclass -class ObjectTypeVersion: - url: str - version: int - objectType: str - status: str - jsonSchema: dict - createdAt: str - modifiedAt: str - publishedAt: str - - -@dataclass -class ObjectType: - url: str - uuid: str - name: str - name_plural: str - description: str - data_classification: str - maintainer_organization: str - maintainer_department: str - contact_person: str - contact_email: str - source: str - update_frequency: str - provider_organization: str - documentation_url: str - labels: dict - created_at: str - modified_at: str - allow_geometry: bool - versions: list +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +# Objects API: https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/maykinmedia/objects-api/master/src/objects/api/v2/openapi.yaml +# Objecttypes API: https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/maykinmedia/objecttypes-api/master/src/objecttypes/api/v2/openapi.yaml + + +class DataClassification(str, Enum): + """ + Confidentiality level of the OBJECTTYPE. + """ + + OPEN = "open" + INTERN = "intern" + CONFIDENTIAL = "confidential" + STRICTLY_CONFIDENTIAL = "strictly_confidential" + + +class UpdateFrequency(str, Enum): + REAL_TIME = "real_time" + HOURLY = "hourly" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + YEARLY = "yearly" + UNKNOWN = "unknown" + + +class CamelBaseModel(BaseModel): + """ + Base model with camelCase converter and default config. + """ + + def to_camel(string: str) -> str: + parts = string.split("_") + return parts[0] + "".join(part.capitalize() for part in parts[1:]) + + model_config = ConfigDict( + populate_by_name=True, + alias_generator=to_camel, + extra="ignore", # override as needed + ) + + +class ObjectRecord(CamelBaseModel): + """ + Represents the state of an OBJECT at a certain time. + """ + + model_config = ConfigDict(extra="allow") + + # Required fields + type_version: int = Field( + ge=0, + le=32767, + description="Version of the OBJECTTYPE", + ) + start_at: str = Field(description="Legal start date (YYYY-MM-DD)") + + # Optional fields + data: dict | None = Field( + default=None, + description="Object data based on OBJECTTYPE", + ) + geometry: dict | None = Field( + default=None, + description="GeoJSON geometry", + ) + correction_for: int | None = Field( + default=None, + ge=0, + description="Index of the record being corrected", + ) + + # Read-only fields + index: int | None = Field( + default=None, + ge=0, + description="Incremental index number", + ) + end_at: str | None = Field( + default=None, + description="Legal end date (YYYY-MM-DD)", + ) + registration_at: str | None = Field( + default=None, + description="Date registered in system (YYYY-MM-DD)", + ) + corrected_by: int | None = Field( + default=None, + ge=0, + description="Index of correcting record", + ) + + +class Object(CamelBaseModel): + """ + Represents an OBJECT with its current/actual RECORD (the state of the OBJECT). + """ + + model_config = ConfigDict(extra="allow") + + # Required fields + type: str = Field(description="URL reference to OBJECTTYPE in Objecttypes API") + record: ObjectRecord = Field(description="Current state of the OBJECT") + + # Read-only fields + url: str | None = Field( + default=None, + description="URL reference to this object", + ) + uuid: str | None = Field( + default=None, + description="Unique identifier (UUID4)", + ) + + +class ObjectTypeVersion(CamelBaseModel): + """ + Represents a VERSION of an OBJECTTYPE. + + A VERSION contains the JSON schema of an OBJECTTYPE at a certain time. + """ + + model_config = ConfigDict(extra="allow") + + # Required fields + json_schema: dict = Field(description="JSON schema for Object validation") + + # Read-only fields + url: str | None = Field( + default=None, + description="URL reference", + ) + version: int | None = Field( + default=None, + ge=0, + description="Integer version number", + ) + object_type: str | None = Field( + default=None, + description="URL reference to OBJECTTYPE", + ) + status: str | None = Field( + default=None, + description="Status: published, draft, deprecated", + ) + created_at: str | None = Field( + default=None, + description="Date created (YYYY-MM-DD)", + ) + modified_at: str | None = Field( + default=None, + description="Date modified (YYYY-MM-DD)", + ) + published_at: str | None = Field( + default=None, + description="Date published (YYYY-MM-DD)", + ) + + +class ObjectType(CamelBaseModel): + """ + Represents an OBJECTTYPE - a collection of OBJECTs of similar form/function. + """ + + model_config = ConfigDict(extra="forbid") + + # Required fields + name: str = Field( + max_length=100, + description="Name of the object type", + ) + name_plural: str = Field( + max_length=100, + description="Plural name of the object type", + ) + + # Optional fields + uuid: str | None = Field( + default=None, + description="Unique identifier (UUID4)", + ) + description: str | None = Field( + default=None, + max_length=1000, + description="Description of the object type", + ) + data_classification: DataClassification | None = Field( + default=None, + description="Confidentiality level", + ) + maintainer_organization: str | None = Field( + default=None, + max_length=200, + description="Responsible organization", + ) + maintainer_department: str | None = Field( + default=None, + max_length=200, + description="Responsible department", + ) + contact_person: str | None = Field( + default=None, + max_length=200, + description="Contact person name", + ) + contact_email: str | None = Field( + default=None, + max_length=200, + description="Contact email", + ) + source: str | None = Field( + default=None, + max_length=200, + description="Source system name", + ) + update_frequency: UpdateFrequency | None = Field( + default=None, + description="Update frequency", + ) + provider_organization: str | None = Field( + default=None, + max_length=200, + description="Publication organization", + ) + documentation_url: str | None = Field( + default=None, + max_length=200, + description="Documentation link", + ) + labels: dict | None = Field( + default=None, + description="Key-value pairs of keywords", + ) + allow_geometry: bool | None = Field( + default=None, + description="Whether objects can have geographic coordinates", + ) + linkable_to_zaken: bool | None = Field( + default=None, + description="Whether objects can link to Zaken", + ) + + # Read-only fields + url: str | None = Field( + default=None, + description="URL reference", + ) + created_at: str | None = Field( + default=None, + description="Date created (YYYY-MM-DD)", + ) + modified_at: str | None = Field( + default=None, + description="Date modified (YYYY-MM-DD)", + ) + versions: list | None = Field( + default=None, + description="List of URL references to versions", + ) diff --git a/objectsapiclient/exceptions.py b/objectsapiclient/exceptions.py new file mode 100644 index 0000000..b29cf35 --- /dev/null +++ b/objectsapiclient/exceptions.py @@ -0,0 +1,28 @@ +from pydantic import ValidationError as PydanticValidationError + + +class ObjectsAPIClientException(Exception): + pass + + +class ObjectsAPIClientValidationError(ObjectsAPIClientException): + """ + Raised when API response data fails Pydantic validation. + + Attributes: + validation_error: The underlying Pydantic ValidationError + errors: List of validation error dicts + model_type: The model class that failed validation + """ + + def __init__( + self, + message: str, + validation_error: PydanticValidationError, + model_type: type | None = None, + ): + super().__init__(message) + + self.validation_error = validation_error + self.errors = validation_error.errors() + self.model_type = model_type diff --git a/objectsapiclient/migrations/0004_alter_objectsclientconfiguration_options_and_more.py b/objectsapiclient/migrations/0004_alter_objectsclientconfiguration_options_and_more.py index 20e6245..93c0c0e 100644 --- a/objectsapiclient/migrations/0004_alter_objectsclientconfiguration_options_and_more.py +++ b/objectsapiclient/migrations/0004_alter_objectsclientconfiguration_options_and_more.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("objectsapiclient", "0003_rename_config_model_fields"), ("zgw_consumers", "0016_auto_20220818_1412"), diff --git a/objectsapiclient/migrations/0005_rename_objectsclientconfiguration.py b/objectsapiclient/migrations/0005_rename_objectsclientconfiguration.py new file mode 100644 index 0000000..ef53a92 --- /dev/null +++ b/objectsapiclient/migrations/0005_rename_objectsclientconfiguration.py @@ -0,0 +1,54 @@ +# Generated manually for model and field renames + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("objectsapiclient", "0004_alter_objectsclientconfiguration_options_and_more"), + ("zgw_consumers", "0016_auto_20220818_1412"), + ] + + operations = [ + migrations.RenameModel( + old_name="ObjectsClientConfiguration", + new_name="ObjectsAPIServiceConfiguration", + ), + migrations.AlterModelOptions( + name="objectsapiserviceconfiguration", + options={"verbose_name": "Objects API service configuration"}, + ), + migrations.RenameField( + model_name="objectsapiserviceconfiguration", + old_name="objects_api_service_config", + new_name="objects_api_client_config", + ), + migrations.RenameField( + model_name="objectsapiserviceconfiguration", + old_name="object_type_api_service_config", + new_name="objecttypes_api_client_config", + ), + migrations.AlterField( + model_name="objectsapiserviceconfiguration", + name="objects_api_client_config", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="objects_api_client_config", + to="zgw_consumers.service", + ), + ), + migrations.AlterField( + model_name="objectsapiserviceconfiguration", + name="objecttypes_api_client_config", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="objecttypes_api_client_config", + to="zgw_consumers.service", + ), + ), + ] diff --git a/objectsapiclient/models.py b/objectsapiclient/models.py index d3ddc5b..a759aaf 100644 --- a/objectsapiclient/models.py +++ b/objectsapiclient/models.py @@ -16,31 +16,34 @@ logger = logging.getLogger(__name__) -class ObjectsClientConfiguration(SingletonModel): +OBJECTTYPE_CACHE_TIMEOUT = 60 # seconds + + +class ObjectsAPIServiceConfiguration(SingletonModel): """ - The Objects API client configuration to retrieve and render forms. + The Objects API service configuration to retrieve and render forms. """ - objects_api_service_config = models.ForeignKey( + objects_api_client_config = models.ForeignKey( "zgw_consumers.Service", on_delete=models.CASCADE, null=True, blank=True, - related_name="objects_api_service_config", + related_name="objects_api_client_config", ) - object_type_api_service_config = models.ForeignKey( + objecttypes_api_client_config = models.ForeignKey( "zgw_consumers.Service", on_delete=models.CASCADE, null=True, blank=True, - related_name="object_type_api_service_config", + related_name="objecttypes_api_client_config", ) class Meta: - verbose_name = _("Objects API client configuration") + verbose_name = _("Objects API service configuration") def __str__(self): - return "Objects API client configuration" + return "Objects API service configuration" class ObjectTypeField(models.SlugField): @@ -68,10 +71,8 @@ def formfield(self, **kwargs): def get_choices( self, - include_blank=True, - blank_choice=BLANK_CHOICE_DASH, - limit_choices_to=None, - ordering=(), + include_blank: bool = True, + blank_choice: list[tuple[str, str]] = BLANK_CHOICE_DASH, ): cache_key = "objectsapiclient_objecttypes" @@ -80,10 +81,12 @@ def get_choices( try: choices = get_object_type_choices() except Exception as e: - logger.exception(e) + logger.exception( + "Failed to fetch object type choices from Objects API: %s", e + ) choices = [] else: - cache.set(cache_key, choices, timeout=60) + cache.set(cache_key, choices, timeout=OBJECTTYPE_CACHE_TIMEOUT) if choices: if include_blank: @@ -106,15 +109,13 @@ class LazyObjectTypeField(ObjectTypeField): def get_choices( self, - include_blank=True, - blank_choice=BLANK_CHOICE_DASH, - limit_choices_to=None, - ordering=None, + include_blank: bool = True, + blank_choice: list[tuple[str, str]] = BLANK_CHOICE_DASH, ): # Check if database table exists (migrations have been run) # Prevents errors during startup before migrations are applied try: - config = ObjectsClientConfiguration.get_solo() + config = ObjectsAPIServiceConfiguration.get_solo() except (ProgrammingError, OperationalError): logger.info( "objectsapiclient_configuration table does not exist yet, " @@ -126,7 +127,10 @@ def get_choices( # Check if Objects API services are configured # Prevents HTTP requests when services aren't set up - if not config.objects_api_service or not config.object_type_api_service: + if ( + not config.objects_api_client_config + or not config.objecttypes_api_client_config + ): logger.info( "Objects API services not configured, skipping objecttypes fetch" ) @@ -137,6 +141,4 @@ def get_choices( return super().get_choices( include_blank=include_blank, blank_choice=blank_choice, - limit_choices_to=limit_choices_to, - ordering=ordering or (), ) diff --git a/objectsapiclient/services.py b/objectsapiclient/services.py new file mode 100644 index 0000000..94629b0 --- /dev/null +++ b/objectsapiclient/services.py @@ -0,0 +1,121 @@ +import logging +from urllib.parse import urljoin + +from django.core.exceptions import ImproperlyConfigured + +from ape_pie import APIClient +from pydantic import ValidationError +from requests.exceptions import HTTPError +from zgw_consumers.client import build_client as build_zgw_client + +from .dataclasses import Object, ObjectType +from .exceptions import ObjectsAPIClientValidationError +from .models import ObjectsAPIServiceConfiguration + +logger = logging.getLogger(__name__) + + +class ObjectsAPIService: + def __init__(self, config: ObjectsAPIServiceConfiguration | None = None): + self.config = config or ObjectsAPIServiceConfiguration.get_solo() + + if ( + not self.config.objects_api_client_config + or not self.config.objecttypes_api_client_config + ): + raise ImproperlyConfigured( + "ObjectsAPIService cannot be instantiated without configurations for " + "Objects API and Objecttypes API" + ) + + self.objects_client: APIClient = build_zgw_client( + service=self.config.objects_api_client_config + ) + self.objecttypes_client: APIClient = build_zgw_client( + service=self.config.objecttypes_api_client_config + ) + + def is_healthy(self) -> tuple[bool, str]: + try: + self.objects_client.request( + "head", + urljoin( + base=self.config.objects_api_client_config.api_root, url="objects" + ), + ) + return True, "" + except HTTPError as exc: + logger.exception("Server did not return a valid response (%s)", exc) + return False, str(exc) + except Exception as exc: + logger.exception("Error making head request to objects api (%s)", exc) + return False, str(exc) + + def object_type_uuid_to_url(self, uuid: str) -> str: + return f"{self.objecttypes_client.base_url}objecttypes/{uuid}/" + + def get_objects(self, object_type_uuid: str | None = None) -> list[Object]: + """ + Retrieve all available Objects from the Objects API. + Generally you'd want to filter the results to a single ObjectType UUID. + + :returns: Returns a list of Object Pydantic models + :raises: ObjectsAPIClientValidationError if API returns malformed data + """ + params = None + + if object_type_uuid: + ot_url = self.object_type_uuid_to_url(object_type_uuid) + params = {"type": ot_url} + + response = self.objects_client.request( + "get", + urljoin(base=self.objects_client.base_url, url="objects"), + params=params, + ) + + response.raise_for_status() + results = response.json().get("results") + + if results is None: # should not happen (cf. API spec), but let's guard anyways + logger.warning("Objects API unexpectedly returned None for results") + return [] + + try: + return [Object.model_validate(obj) for obj in results] + except ValidationError as exc: + logger.exception("Failed to validate Object data from Objects API") + raise ObjectsAPIClientValidationError( + "API returned invalid object data", + validation_error=exc, + model_type=Object, + ) from exc + + def get_object_types(self) -> list[ObjectType]: + """ + Retrieve all available Object Types + + :returns: Returns a list of ObjectType Pydantic models + :raises: ObjectsAPIClientValidationError if API returns malformed data + """ + response = self.objecttypes_client.request( + method="get", + url=urljoin(self.objecttypes_client.base_url, "objecttypes"), + ) + + response.raise_for_status() + results = response.json().get("results") + + if results is None: # should not happen (cf. API spec), but let's guard anyways + logger.warning("Objecttypes API unexpectedly returned None for results") + return [] + + try: + return [ObjectType.model_validate(obj) for obj in results] + except ValidationError as exc: + logger.exception("Failed to validate ObjectType data from Objecttype API") + raise ObjectsAPIClientValidationError( + "API returned invalid object data", + validation_error=exc, + model_type=ObjectType, + ) from exc diff --git a/objectsapiclient/utils.py b/objectsapiclient/utils.py index 344f682..0c01714 100644 --- a/objectsapiclient/utils.py +++ b/objectsapiclient/utils.py @@ -3,16 +3,14 @@ logger = logging.getLogger(__name__) -def get_object_type_choices(use_uuids=False): - from .client import Client +def get_object_type_choices(): + from objectsapiclient.services import ObjectsAPIService - client = Client() - if not client: - return [] + service = ObjectsAPIService() - objecttypes = client.get_object_types() + object_types = service.get_object_types() return sorted( - [(item.uuid, item.name) for item in objecttypes], + [(item.uuid, item.name) for item in object_types], key=lambda entry: entry[1], ) diff --git a/pyproject.toml b/pyproject.toml index 6989176..1d3a73d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ classifiers = [ dependencies = [ "django >= 4.2", "django-solo", + "ape_pie", + "pydantic >= 2.0", "requests", "zgw_consumers", ] @@ -45,10 +47,7 @@ tests = [ "ruff", ] coverage = ["pytest-cov"] -release = [ - "bump-my-version", - "twine", -] +release = ["twine"] [project.urls] Homepage = "https://github.com/maykinmedia/objects-api-client-django" @@ -63,20 +62,6 @@ include-package-data = true [tool.setuptools.packages.find] include = ["objectsapiclient", "objectsapiclient.*"] -[tool.bumpversion] -current_version = "0.4.1" -tag = true -tag_name = "v{new_version}" -commit = false - -[[tool.bumpversion.files]] -filename = "pyproject.toml" - -[[tool.bumpversion.files]] -filename = "README.rst" -search = ":Version: {current_version}" -replace = ":Version: {new_version}" - [tool.pytest.ini_options] testpaths = ["tests"] DJANGO_SETTINGS_MODULE = "testapp.settings" diff --git a/testapp/migrations/0002_alter_page_id.py b/testapp/migrations/0002_alter_page_id.py new file mode 100644 index 0000000..b12c670 --- /dev/null +++ b/testapp/migrations/0002_alter_page_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2025-12-08 08:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("testapp", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="page", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/testapp/settings.py b/testapp/settings.py index ede7176..a3b0bdc 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -60,3 +60,5 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "media") MEDIA_URL = "/media/" + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py new file mode 100644 index 0000000..a103e85 --- /dev/null +++ b/tests/test_dataclasses.py @@ -0,0 +1,351 @@ +import json + +import pytest +from pydantic import ValidationError + +from objectsapiclient.dataclasses import ( + DataClassification, + Object, + ObjectRecord, + ObjectType, + ObjectTypeVersion, + UpdateFrequency, +) + + +class TestObjectTypeVersion: + @pytest.mark.parametrize("json_schema_arg", ["json_schema", "jsonSchema"]) + def test_create_with_snake_and_camel_case(self, json_schema_arg): + version = ObjectTypeVersion.model_validate( + {json_schema_arg: {"type": "object"}} + ) + + assert version.json_schema == {"type": "object"} + + def test_create_with_all_fields(self): + version = ObjectTypeVersion.model_validate( + { + "jsonSchema": {"type": "object", "properties": {}}, + "url": "https://example.com/versions/1", + "version": 1, + "objectType": "https://example.com/objecttypes/123", + "status": "published", + "createdAt": "2023-01-01", + "modifiedAt": "2023-01-02", + "publishedAt": "2023-01-03", + } + ) + + assert version.json_schema == {"type": "object", "properties": {}} + assert version.url == "https://example.com/versions/1" + assert version.version == 1 + assert version.object_type == "https://example.com/objecttypes/123" + assert version.status == "published" + assert version.created_at == "2023-01-01" + assert version.modified_at == "2023-01-02" + assert version.published_at == "2023-01-03" + + def test_extra_fields_allowed(self): + version = ObjectTypeVersion.model_validate( + {"jsonSchema": {"type": "object"}, "extraField": "allowed"} + ) + + assert version.json_schema == {"type": "object"} + + def test_model_dump_uses_aliases(self): + version = ObjectTypeVersion.model_validate( + { + "jsonSchema": {"type": "object"}, + "objectType": "https://example.com/objecttypes/123", + "createdAt": "2023-01-01", + } + ) + dumped = version.model_dump(by_alias=True, exclude_none=True) + + assert "jsonSchema" in dumped + assert "objectType" in dumped + assert "createdAt" in dumped + assert "json_schema" not in dumped + assert "object_type" not in dumped + + +class TestObjectType: + @pytest.mark.parametrize("name_plural_arg", ["name_plural", "namePlural"]) + def test_create_with_snake_and_camel_case(self, name_plural_arg): + obj_type = ObjectType.model_validate( + {"name": "Person", name_plural_arg: "Persons"} + ) + assert obj_type.name == "Person" + assert obj_type.name_plural == "Persons" + + def test_create_with_all_fields(self): + obj_type = ObjectType.model_validate( + { + "name": "Person", + "namePlural": "Persons", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "description": "A person object type", + "dataClassification": "confidential", + "maintainerOrganization": "Acme Corp", + "maintainerDepartment": "IT", + "contactPerson": "John Doe", + "contactEmail": "john@example.com", + "source": "HR System", + "updateFrequency": "daily", + "providerOrganization": "Acme Corp", + "documentationUrl": "https://docs.example.com", + "labels": {"category": "people"}, + "allowGeometry": True, + "linkableToZaken": False, + "url": "https://example.com/objecttypes/123", + "createdAt": "2023-01-01", + "modifiedAt": "2023-01-02", + "versions": ["https://example.com/versions/1"], + } + ) + + assert obj_type.name == "Person" + assert obj_type.name_plural == "Persons" + assert obj_type.uuid == "550e8400-e29b-41d4-a716-446655440000" + assert obj_type.description == "A person object type" + assert obj_type.data_classification == DataClassification.CONFIDENTIAL + assert obj_type.maintainer_organization == "Acme Corp" + assert obj_type.maintainer_department == "IT" + assert obj_type.contact_person == "John Doe" + assert obj_type.contact_email == "john@example.com" + assert obj_type.source == "HR System" + assert obj_type.update_frequency == UpdateFrequency.DAILY + assert obj_type.provider_organization == "Acme Corp" + assert obj_type.documentation_url == "https://docs.example.com" + assert obj_type.labels == {"category": "people"} + assert obj_type.allow_geometry is True + assert obj_type.linkable_to_zaken is False + + def test_data_classification_enum_validation(self): + """Test that string values from API are converted to enum.""" + obj_type = ObjectType.model_validate( + {"name": "Person", "namePlural": "Persons", "dataClassification": "open"} + ) + assert obj_type.data_classification == DataClassification.OPEN + + def test_data_classification_invalid_value(self): + """Test that invalid enum values are rejected.""" + with pytest.raises(ValidationError) as exc_info: + ObjectType.model_validate( + { + "name": "Person", + "namePlural": "Persons", + "dataClassification": "invalid", + } + ) + errors = exc_info.value.errors() + assert any(e["loc"] == ("dataClassification",) for e in errors) + + def test_update_frequency_enum_validation(self): + """Test that string values from API are converted to enum.""" + obj_type = ObjectType.model_validate( + {"name": "Person", "namePlural": "Persons", "updateFrequency": "weekly"} + ) + assert obj_type.update_frequency == UpdateFrequency.WEEKLY + + def test_update_frequency_invalid_value(self): + with pytest.raises(ValidationError) as exc_info: + ObjectType.model_validate( + { + "name": "Person", + "namePlural": "Persons", + "updateFrequency": "invalid", + } + ) + errors = exc_info.value.errors() + assert any(e["loc"] == ("updateFrequency",) for e in errors) + + def test_extra_fields_forbidden(self): + with pytest.raises(ValidationError) as exc_info: + ObjectType.model_validate( + {"name": "Person", "namePlural": "Persons", "extraField": "not_allowed"} + ) + errors = exc_info.value.errors() + assert any(e["type"] == "extra_forbidden" for e in errors) + + def test_model_dump_uses_aliases(self): + """Test that model_dump with by_alias=True uses camelCase aliases.""" + obj_type = ObjectType.model_validate( + { + "name": "Person", + "namePlural": "Persons", + "dataClassification": "open", + "updateFrequency": "daily", + } + ) + dumped = obj_type.model_dump(by_alias=True, exclude_none=True) + + assert "namePlural" in dumped + assert "dataClassification" in dumped + assert "updateFrequency" in dumped + assert "name_plural" not in dumped + assert "data_classification" not in dumped + + def test_model_dump_json_serializes_enums_as_strings(self): + """Test that enums serialize as strings in JSON output.""" + obj_type = ObjectType.model_validate( + { + "name": "Person", + "namePlural": "Persons", + "dataClassification": "confidential", + "updateFrequency": "weekly", + } + ) + + # Serialize to dict - enums should remain as enum instances + dumped = obj_type.model_dump(exclude_none=True) + assert dumped["data_classification"] == DataClassification.CONFIDENTIAL + assert dumped["update_frequency"] == UpdateFrequency.WEEKLY + + # Serialize to JSON - enums should be strings + json_str = obj_type.model_dump_json(by_alias=True, exclude_none=True) + json_data = json.loads(json_str) + assert json_data["dataClassification"] == "confidential" + assert json_data["updateFrequency"] == "weekly" + + def test_populate_by_name_accepts_snake_and_camel_case(self): + obj_type = ObjectType.model_validate( + { + "name": "Person", + "namePlural": "Persons", + "data_classification": "open", + "updateFrequency": "daily", + } + ) + assert obj_type.name_plural == "Persons" + assert obj_type.data_classification == DataClassification.OPEN + assert obj_type.update_frequency == UpdateFrequency.DAILY + + +class TestObjectRecord: + @pytest.mark.parametrize("type_version_arg", ["typeVersion", "type_version"]) + def test_create_with_different_case(self, type_version_arg): + record = ObjectRecord.model_validate( + {type_version_arg: 1, "startAt": "2023-01-01"} + ) + + assert record.type_version == 1 + assert record.start_at == "2023-01-01" + assert record.data is None + assert record.geometry is None + assert record.correction_for is None + + def test_create_with_all_fields(self): + record = ObjectRecord.model_validate( + { + "typeVersion": 1, + "startAt": "2023-01-01", + "data": {"foo": "bar"}, + "geometry": {"type": "Point", "coordinates": [4.9, 52.3]}, + "correctionFor": 5, + "index": 10, + "endAt": "2023-12-31", + "registrationAt": "2023-01-02", + "correctedBy": 15, + } + ) + + assert record.type_version == 1 + assert record.start_at == "2023-01-01" + assert record.data == {"foo": "bar"} + assert record.geometry == {"type": "Point", "coordinates": [4.9, 52.3]} + assert record.correction_for == 5 + assert record.index == 10 + assert record.end_at == "2023-12-31" + assert record.registration_at == "2023-01-02" + assert record.corrected_by == 15 + + def test_extra_fields_allowed(self): + record = ObjectRecord.model_validate( + {"typeVersion": 1, "startAt": "2023-01-01", "extraField": "allowed"} + ) + assert record.type_version == 1 + + def test_model_dump_uses_aliases(self): + """Test that model_dump with by_alias=True uses camelCase aliases.""" + record = ObjectRecord.model_validate( + { + "typeVersion": 1, + "startAt": "2023-01-01", + "correctionFor": 5, + "endAt": "2023-12-31", + } + ) + dumped = record.model_dump(by_alias=True, exclude_none=True) + + assert "typeVersion" in dumped + assert "startAt" in dumped + assert "correctionFor" in dumped + assert "endAt" in dumped + assert "type_version" not in dumped + assert "start_at" not in dumped + + def test_model_dump_without_aliases(self): + """Test that model_dump without by_alias uses snake_case.""" + record = ObjectRecord.model_validate( + {"typeVersion": 1, "startAt": "2023-01-01"} + ) + dumped = record.model_dump(exclude_none=True) + + assert "type_version" in dumped + assert "start_at" in dumped + assert "typeVersion" not in dumped + assert "startAt" not in dumped + + +class TestObject: + def test_create_with_required_fields(self): + obj = Object.model_validate( + { + "type": "https://example.com/objecttypes/123", + "record": {"typeVersion": 1, "startAt": "2023-01-01"}, + } + ) + + assert obj.type == "https://example.com/objecttypes/123" + assert isinstance(obj.record, ObjectRecord) + assert obj.record.type_version == 1 + assert obj.record.start_at == "2023-01-01" + + def test_create_with_all_fields(self): + obj = Object.model_validate( + { + "type": "https://example.com/objecttypes/123", + "record": {"typeVersion": 1, "startAt": "2023-01-01"}, + "url": "https://example.com/objects/456", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + } + ) + + assert obj.url == "https://example.com/objects/456" + assert obj.uuid == "550e8400-e29b-41d4-a716-446655440000" + + def test_extra_fields_allowed(self): + obj = Object.model_validate( + { + "type": "https://example.com/objecttypes/123", + "record": {"typeVersion": 1, "startAt": "2023-01-01"}, + "extraField": "allowed", + } + ) + + assert obj.type == "https://example.com/objecttypes/123" + + def test_model_dump_nested_object(self): + """Test that nested ObjectRecord serializes with aliases.""" + obj = Object.model_validate( + { + "type": "https://example.com/objecttypes/123", + "record": {"typeVersion": 1, "startAt": "2023-01-01"}, + } + ) + dumped = obj.model_dump(by_alias=True, exclude_none=True) + + assert dumped["type"] == "https://example.com/objecttypes/123" + assert dumped["record"]["typeVersion"] == 1 + assert dumped["record"]["startAt"] == "2023-01-01" diff --git a/tests/test_model_fields.py b/tests/test_model_fields.py new file mode 100644 index 0000000..648ee5c --- /dev/null +++ b/tests/test_model_fields.py @@ -0,0 +1,405 @@ +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.core.cache import cache +from django.db import OperationalError, ProgrammingError +from django.db.models.fields import BLANK_CHOICE_DASH +from django.forms.fields import TypedChoiceField +from django.forms.widgets import Select + +import pytest +from requests.exceptions import HTTPError + +from objectsapiclient.models import ( + LazyObjectTypeField, + ObjectsAPIServiceConfiguration, + ObjectTypeField, +) + + +@pytest.fixture +def clear_cache(): + """Clear cache before each test to ensure clean state.""" + cache.clear() + yield + cache.clear() + + +class TestObjectTypeField: + # + # ObjectTypeField.formfield() + # + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_returns_typed_choice_field(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField( + verbose_name="Object Type", help_text="Select an object type" + ) + formfield = field.formfield() + + assert isinstance(formfield, TypedChoiceField) + + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_uses_select_widget(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField() + formfield = field.formfield() + + assert isinstance(formfield.widget, Select) + + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_required_when_blank_false(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField(blank=False) + formfield = field.formfield() + + assert formfield.required is True + + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_not_required_when_blank_true(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField(blank=True) + formfield = field.formfield() + + assert formfield.required is False + + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_uses_verbose_name_as_label(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField(verbose_name="object type") + formfield = field.formfield() + + # capfirst should capitalize the first letter + assert formfield.label == "Object type" + + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_uses_help_text(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + help_text = "Choose the type of object to create" + field = ObjectTypeField(help_text=help_text) + formfield = field.formfield() + + assert formfield.help_text == help_text + + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_choices_uses_get_choices(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1"), ("uuid-2", "Type 2")] + + field = ObjectTypeField(blank=False) + formfield = field.formfield() + + # Access the choices attribute + # Should be a CallableChoiceIterator that wraps the get_choices partial + assert hasattr(formfield, "choices") + + # Verify that when choices are evaluated, they come from get_object_type_choices + # Convert to list to trigger the lazy evaluation + choices_as_list = [choice for choice in formfield.choices] + + # Should return the choices from get_object_type_choices + assert ("uuid-1", "Type 1") in choices_as_list + assert ("uuid-2", "Type 2") in choices_as_list + + @patch("objectsapiclient.models.get_object_type_choices") + def test_formfield_coerce_uses_to_python(self, mock_get_choices): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField() + formfield = field.formfield() + + # coerce should be the field's to_python method + assert formfield.coerce == field.to_python + + # + # ObjectTypeField.get_choices() + # + @patch("objectsapiclient.models.get_object_type_choices") + @pytest.mark.parametrize( + "include_blank,expected", + [ + (True, [BLANK_CHOICE_DASH[0], ("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + (False, [("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + ], + ) + def test_get_choices_success( + self, mock_get_choices, clear_cache, include_blank, expected + ): + mock_get_choices.return_value = [ + ("uuid-1", "Type 1"), + ("uuid-2", "Type 2"), + ] + + field = ObjectTypeField() + choices = field.get_choices(include_blank=False) + + assert choices == [("uuid-1", "Type 1"), ("uuid-2", "Type 2")] + mock_get_choices.assert_called_once() + + # Verify caching - second call should not call the function again + cache_key = "objectsapiclient_objecttypes" + assert cache.get(cache_key) == [("uuid-1", "Type 1"), ("uuid-2", "Type 2")] + + @patch("objectsapiclient.models.get_object_type_choices") + def test_get_choices_uses_cache(self, mock_get_choices, clear_cache): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField() + + # First call + choices1 = field.get_choices(include_blank=False) + assert mock_get_choices.call_count == 1 + + # Second call should use cache + choices2 = field.get_choices(include_blank=False) + assert mock_get_choices.call_count == 1 # Not called again + assert choices1 == choices2 + + @patch("objectsapiclient.models.logger") + @patch("objectsapiclient.models.get_object_type_choices") + def test_get_choices_handles_exception( + self, mock_get_choices, mock_logger, clear_cache + ): + """ + Test that exceptions from get_object_type_choices are caught and logged + """ + mock_get_choices.side_effect = HTTPError("API connection failed") + + field = ObjectTypeField() + choices = field.get_choices(include_blank=False) + + # Should return empty list on exception + assert choices == [] + + # Verify exception was logged + mock_logger.exception.assert_called_once() + log_message = mock_logger.exception.call_args[0][0] + assert "Failed to fetch object type choices" in log_message + + @patch("objectsapiclient.models.logger") + @patch("objectsapiclient.models.get_object_type_choices") + def test_get_choices_handles_exception_with_blank( + self, mock_get_choices, mock_logger, clear_cache + ): + """ + Test exception handling returns empty list, no blank choice added to empty list + """ + mock_get_choices.side_effect = ConnectionError("Network unavailable") + + field = ObjectTypeField() + choices = field.get_choices(include_blank=True) + + # Empty list should remain empty even with include_blank=True + # because blank is only added if choices exist + assert choices == [] + + # Verify exception was logged + mock_logger.exception.assert_called_once() + + +class TestLazyObjectTypeField: + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] + ) + def test_get_choices_when_table_does_not_exist( + self, mock_get_solo, include_blank, expected + ): + """ + Test that get_choices returns blank choice or empty list when table + doesn't exist + """ + mock_get_solo.side_effect = ProgrammingError("relation does not exist") + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + mock_get_solo.assert_called_once() + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] + ) + def test_get_choices_when_operational_error( + self, mock_get_solo, include_blank, expected + ): + """ + Test that get_choices handles OperationalError gracefully + """ + mock_get_solo.side_effect = OperationalError("database is locked") + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + mock_get_solo.assert_called_once() + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] + ) + def test_get_choices_when_services_not_configured( + self, mock_get_solo, include_blank, expected + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = None + mock_config.objecttypes_api_client_config = None + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "objects_api,object_type_api,include_blank,expected", + [ + (Mock(), None, True, BLANK_CHOICE_DASH), + (Mock(), None, False, []), + (None, Mock(), True, BLANK_CHOICE_DASH), + (None, Mock(), False, []), + ], + ) + def test_get_choices_when_only_one_service_configured( + self, + mock_get_solo, + objects_api, + object_type_api, + include_blank, + expected, + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = objects_api + mock_config.objecttypes_api_client_config = object_type_api + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", + [ + (True, [BLANK_CHOICE_DASH[0], ("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + (False, [("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + ], + ) + def test_get_choices_when_fully_configured( + self, mock_get_solo, mock_get_choices, clear_cache, include_blank, expected + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = Mock() + mock_config.objecttypes_api_client_config = Mock() + mock_get_solo.return_value = mock_config + + mock_get_choices.return_value = [ + ("uuid-1", "Type 1"), + ("uuid-2", "Type 2"), + ] + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + mock_get_choices.assert_called_once() + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_database_error_prevention_during_migrations(self, mock_get_solo): + """ + Test that LazyObjectTypeField prevents errors during migrations + """ + mock_get_solo.side_effect = ProgrammingError( + "relation 'objectsapiclient_objectsclientconfiguration' does not exist" + ) + + field = LazyObjectTypeField() + choices = field.get_choices() + + assert choices == BLANK_CHOICE_DASH + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_prevents_unnecessary_http_requests_on_startup( + self, mock_get_solo, mock_get_choices + ): + """ + Test that LazyObjectTypeField doesn't make HTTP requests when not configured + """ + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = None + mock_config.objecttypes_api_client_config = None + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + field.get_choices() + + # get_object_type_choices should not be called + mock_get_choices.assert_not_called() + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_uses_correct_field_names_when_checking_configuration( + self, mock_get_solo, mock_get_choices + ): + """ + Regression test 1 for accessing non-existent config.objects_api_service + instead of config.objects_api_client_config: no API services configured + """ + # A SimpleNamespace object only has the exact attributes we set (unlike Mock) + # Will raise AttributeError if code tries to access wrong attribute names + mock_config = SimpleNamespace( + objects_api_client_config=None, objecttypes_api_client_config=None + ) + + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + + # Accessing the wrong fields will raise AttributeError + choices = field.get_choices(include_blank=False) + + # Should return empty list since services are not configured + assert choices == [] + # Should not make HTTP requests + mock_get_choices.assert_not_called() + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_correctly_detects_configured_services( + self, mock_get_solo, mock_get_choices, clear_cache + ): + """ + Regression test 2 for accessing non-existent config.objects_api_service + instead of config.objects_api_client_config: both API services configured + """ + mock_service = Mock() + mock_service.api_root = "https://example.com/api/" + + # A SimpleNamespace object only has the exact attributes we set (unlike Mock) + # Will raise AttributeError if code tries to access wrong attribute names + mock_config = SimpleNamespace( + objects_api_client_config=mock_service, + objecttypes_api_client_config=mock_service, + ) + + mock_get_solo.return_value = mock_config + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = LazyObjectTypeField() + + # Accessing the wrong fields will raise AttributeError + choices = field.get_choices(include_blank=False) + + # Should call get_object_type_choices since services are configured + mock_get_choices.assert_called_once() + assert choices == [("uuid-1", "Type 1")] diff --git a/tests/test_objects_api.py b/tests/test_objects_api.py deleted file mode 100644 index 2a9fd8e..0000000 --- a/tests/test_objects_api.py +++ /dev/null @@ -1,209 +0,0 @@ -from unittest.mock import Mock, patch - -from django.core.cache import cache -from django.db import OperationalError, ProgrammingError -from django.db.models.fields import BLANK_CHOICE_DASH - -import pytest - -from objectsapiclient.models import ( - LazyObjectTypeField, - ObjectsClientConfiguration, - ObjectTypeField, -) - - -@pytest.fixture -def clear_cache(): - """Clear cache before each test to ensure clean state.""" - cache.clear() - yield - cache.clear() - - -class TestObjectTypeField: - @patch("objectsapiclient.models.get_object_type_choices") - @pytest.mark.parametrize( - "include_blank,expected", - [ - (True, [BLANK_CHOICE_DASH[0], ("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), - (False, [("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), - ], - ) - def test_get_choices_success( - self, mock_get_choices, clear_cache, include_blank, expected - ): - mock_get_choices.return_value = [ - ("uuid-1", "Type 1"), - ("uuid-2", "Type 2"), - ] - - field = ObjectTypeField() - choices = field.get_choices(include_blank=False) - - assert choices == [("uuid-1", "Type 1"), ("uuid-2", "Type 2")] - mock_get_choices.assert_called_once() - - # Verify caching - second call should not call the function again - cache_key = "objectsapiclient_objecttypes" - assert cache.get(cache_key) == [("uuid-1", "Type 1"), ("uuid-2", "Type 2")] - - @patch("objectsapiclient.models.get_object_type_choices") - def test_get_choices_uses_cache(self, mock_get_choices, clear_cache): - mock_get_choices.return_value = [("uuid-1", "Type 1")] - - field = ObjectTypeField() - - # First call - choices1 = field.get_choices(include_blank=False) - assert mock_get_choices.call_count == 1 - - # Second call should use cache - choices2 = field.get_choices(include_blank=False) - assert mock_get_choices.call_count == 1 # Not called again - assert choices1 == choices2 - - -class TestLazyObjectTypeField: - @patch("objectsapiclient.models.ObjectsClientConfiguration.get_solo") - @pytest.mark.parametrize( - "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] - ) - def test_get_choices_when_table_does_not_exist( - self, mock_get_solo, include_blank, expected - ): - """ - Test that get_choices returns blank choice or empty list when table - doesn't exist - """ - mock_get_solo.side_effect = ProgrammingError("relation does not exist") - - field = LazyObjectTypeField() - choices = field.get_choices(include_blank=include_blank) - - assert choices == expected - mock_get_solo.assert_called_once() - - @patch("objectsapiclient.models.ObjectsClientConfiguration.get_solo") - @pytest.mark.parametrize( - "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] - ) - def test_get_choices_when_operational_error( - self, mock_get_solo, include_blank, expected - ): - """ - Test that get_choices handles OperationalError gracefully - """ - mock_get_solo.side_effect = OperationalError("database is locked") - - field = LazyObjectTypeField() - choices = field.get_choices(include_blank=include_blank) - - assert choices == expected - mock_get_solo.assert_called_once() - - @patch("objectsapiclient.models.ObjectsClientConfiguration.get_solo") - @pytest.mark.parametrize( - "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] - ) - def test_get_choices_when_services_not_configured( - self, mock_get_solo, include_blank, expected - ): - mock_config = Mock(spec=ObjectsClientConfiguration) - mock_config.objects_api_service = None - mock_config.object_type_api_service = None - mock_get_solo.return_value = mock_config - - field = LazyObjectTypeField() - choices = field.get_choices(include_blank=include_blank) - - assert choices == expected - - @patch("objectsapiclient.models.ObjectsClientConfiguration.get_solo") - @pytest.mark.parametrize( - "objects_api,object_type_api,include_blank,expected", - [ - (Mock(), None, True, BLANK_CHOICE_DASH), - (Mock(), None, False, []), - (None, Mock(), True, BLANK_CHOICE_DASH), - (None, Mock(), False, []), - ], - ) - def test_get_choices_when_only_one_service_configured( - self, - mock_get_solo, - objects_api, - object_type_api, - include_blank, - expected, - ): - mock_config = Mock(spec=ObjectsClientConfiguration) - mock_config.objects_api_service = objects_api - mock_config.object_type_api_service = object_type_api - mock_get_solo.return_value = mock_config - - field = LazyObjectTypeField() - choices = field.get_choices(include_blank=include_blank) - - assert choices == expected - - @patch("objectsapiclient.models.get_object_type_choices") - @patch("objectsapiclient.models.ObjectsClientConfiguration.get_solo") - @pytest.mark.parametrize( - "include_blank,expected", - [ - (True, [BLANK_CHOICE_DASH[0], ("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), - (False, [("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), - ], - ) - def test_get_choices_when_fully_configured( - self, mock_get_solo, mock_get_choices, clear_cache, include_blank, expected - ): - mock_config = Mock(spec=ObjectsClientConfiguration) - mock_config.objects_api_service = Mock() - mock_config.object_type_api_service = Mock() - mock_get_solo.return_value = mock_config - - mock_get_choices.return_value = [ - ("uuid-1", "Type 1"), - ("uuid-2", "Type 2"), - ] - - field = LazyObjectTypeField() - choices = field.get_choices(include_blank=include_blank) - - assert choices == expected - mock_get_choices.assert_called_once() - - @patch("objectsapiclient.models.ObjectsClientConfiguration.get_solo") - def test_database_error_prevention_during_migrations(self, mock_get_solo): - """ - Test that LazyObjectTypeField prevents errors during migrations - """ - mock_get_solo.side_effect = ProgrammingError( - "relation 'objectsapiclient_objectsclientconfiguration' does not exist" - ) - - field = LazyObjectTypeField() - choices = field.get_choices() - - assert choices == BLANK_CHOICE_DASH - - @patch("objectsapiclient.models.get_object_type_choices") - @patch("objectsapiclient.models.ObjectsClientConfiguration.get_solo") - def test_prevents_unnecessary_http_requests_on_startup( - self, mock_get_solo, mock_get_choices - ): - """ - Test that LazyObjectTypeField doesn't make HTTP requests when not configured - """ - mock_config = Mock(spec=ObjectsClientConfiguration) - mock_config.objects_api_service = None - mock_config.object_type_api_service = None - mock_get_solo.return_value = mock_config - - field = LazyObjectTypeField() - field.get_choices() - - # get_object_type_choices should not be called - mock_get_choices.assert_not_called() diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..b4a8b49 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,880 @@ +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.core.cache import cache +from django.core.exceptions import ImproperlyConfigured +from django.db.models.fields import BLANK_CHOICE_DASH +from django.db.utils import OperationalError, ProgrammingError + +import pytest +from pydantic import ValidationError +from requests.exceptions import HTTPError, Timeout + +from objectsapiclient.exceptions import ObjectsAPIClientValidationError +from objectsapiclient.models import ( + LazyObjectTypeField, + ObjectsAPIServiceConfiguration, + ObjectTypeField, +) +from objectsapiclient.services import ObjectsAPIService + + +@pytest.fixture +def clear_cache(): + """Clear cache before each test to ensure clean state.""" + cache.clear() + yield + cache.clear() + + +class TestObjectTypeField: + @patch("objectsapiclient.models.get_object_type_choices") + @pytest.mark.parametrize( + "include_blank,expected", + [ + (True, [BLANK_CHOICE_DASH[0], ("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + (False, [("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + ], + ) + def test_get_choices_success( + self, mock_get_choices, clear_cache, include_blank, expected + ): + mock_get_choices.return_value = [ + ("uuid-1", "Type 1"), + ("uuid-2", "Type 2"), + ] + + field = ObjectTypeField() + choices = field.get_choices(include_blank=False) + + assert choices == [("uuid-1", "Type 1"), ("uuid-2", "Type 2")] + mock_get_choices.assert_called_once() + + # Verify caching - second call should not call the function again + cache_key = "objectsapiclient_objecttypes" + assert cache.get(cache_key) == [("uuid-1", "Type 1"), ("uuid-2", "Type 2")] + + @patch("objectsapiclient.models.get_object_type_choices") + def test_get_choices_uses_cache(self, mock_get_choices, clear_cache): + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = ObjectTypeField() + + # First call + choices1 = field.get_choices(include_blank=False) + assert mock_get_choices.call_count == 1 + + # Second call should use cache + choices2 = field.get_choices(include_blank=False) + assert mock_get_choices.call_count == 1 # Not called again + assert choices1 == choices2 + + +class TestLazyObjectTypeField: + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] + ) + def test_get_choices_when_table_does_not_exist( + self, mock_get_solo, include_blank, expected + ): + """ + Test that get_choices returns blank choice or empty list when table + doesn't exist + """ + mock_get_solo.side_effect = ProgrammingError("relation does not exist") + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + mock_get_solo.assert_called_once() + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] + ) + def test_get_choices_when_operational_error( + self, mock_get_solo, include_blank, expected + ): + """ + Test that get_choices handles OperationalError gracefully + """ + mock_get_solo.side_effect = OperationalError("database is locked") + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + mock_get_solo.assert_called_once() + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", [(True, BLANK_CHOICE_DASH), (False, [])] + ) + def test_get_choices_when_services_not_configured( + self, mock_get_solo, include_blank, expected + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = None + mock_config.objecttypes_api_client_config = None + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "objects_api,object_type_api,include_blank,expected", + [ + (Mock(), None, True, BLANK_CHOICE_DASH), + (Mock(), None, False, []), + (None, Mock(), True, BLANK_CHOICE_DASH), + (None, Mock(), False, []), + ], + ) + def test_get_choices_when_only_one_service_configured( + self, + mock_get_solo, + objects_api, + object_type_api, + include_blank, + expected, + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = objects_api + mock_config.objecttypes_api_client_config = object_type_api + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + @pytest.mark.parametrize( + "include_blank,expected", + [ + (True, [BLANK_CHOICE_DASH[0], ("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + (False, [("uuid-1", "Type 1"), ("uuid-2", "Type 2")]), + ], + ) + def test_get_choices_when_fully_configured( + self, mock_get_solo, mock_get_choices, clear_cache, include_blank, expected + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = Mock() + mock_config.objecttypes_api_client_config = Mock() + mock_get_solo.return_value = mock_config + + mock_get_choices.return_value = [ + ("uuid-1", "Type 1"), + ("uuid-2", "Type 2"), + ] + + field = LazyObjectTypeField() + choices = field.get_choices(include_blank=include_blank) + + assert choices == expected + mock_get_choices.assert_called_once() + + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_database_error_prevention_during_migrations(self, mock_get_solo): + """ + Test that LazyObjectTypeField prevents errors during migrations + """ + mock_get_solo.side_effect = ProgrammingError( + "relation 'objectsapiclient_objectsclientconfiguration' does not exist" + ) + + field = LazyObjectTypeField() + choices = field.get_choices() + + assert choices == BLANK_CHOICE_DASH + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_prevents_unnecessary_http_requests_on_startup( + self, mock_get_solo, mock_get_choices + ): + """ + Test that LazyObjectTypeField doesn't make HTTP requests when not configured + """ + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = None + mock_config.objecttypes_api_client_config = None + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + field.get_choices() + + # get_object_type_choices should not be called + mock_get_choices.assert_not_called() + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_uses_correct_field_names_when_checking_configuration( + self, mock_get_solo, mock_get_choices + ): + """ + Regression test 1 for accessing non-existent config.objects_api_service + instead of config.objects_api_client_config: no API services configured + """ + # A SimpleNamespace object only has the exact attributes we set (unlike Mock) + # Will raise AttributeError if code tries to access wrong attribute names + mock_config = SimpleNamespace( + objects_api_client_config=None, objecttypes_api_client_config=None + ) + + mock_get_solo.return_value = mock_config + + field = LazyObjectTypeField() + + # Accessing the wrong fields will raise AttributeError + choices = field.get_choices(include_blank=False) + + # Should return empty list since services are not configured + assert choices == [] + # Should not make HTTP requests + mock_get_choices.assert_not_called() + + @patch("objectsapiclient.models.get_object_type_choices") + @patch("objectsapiclient.models.ObjectsAPIServiceConfiguration.get_solo") + def test_correctly_detects_configured_services( + self, mock_get_solo, mock_get_choices, clear_cache + ): + """ + Regression test 2 for accessing non-existent config.objects_api_service + instead of config.objects_api_client_config: both API services configured + """ + mock_service = Mock() + mock_service.api_root = "https://example.com/api/" + + # A SimpleNamespace object only has the exact attributes we set (unlike Mock) + # Will raise AttributeError if code tries to access wrong attribute names + mock_config = SimpleNamespace( + objects_api_client_config=mock_service, + objecttypes_api_client_config=mock_service, + ) + + mock_get_solo.return_value = mock_config + mock_get_choices.return_value = [("uuid-1", "Type 1")] + + field = LazyObjectTypeField() + + # Accessing the wrong fields will raise AttributeError + choices = field.get_choices(include_blank=False) + + # Should call get_object_type_choices since services are configured + mock_get_choices.assert_called_once() + assert choices == [("uuid-1", "Type 1")] + + +class TestObjectsAPIService: + """ + Tests for ObjectsAPIService (with tests for Objects API and Objecttypes API clients) + """ + + @pytest.fixture + def mock_config(self): + config = Mock(spec=ObjectsAPIServiceConfiguration) + + # Mock the objects API service config + objects_service = Mock() + objects_service.api_root = "https://objects.example.com/api/v1/" + config.objects_api_client_config = objects_service + + # Mock the object types API service config + object_types_service = Mock() + object_types_service.api_root = "https://objecttypes.example.com/api/v1/" + config.objecttypes_api_client_config = object_types_service + + return config + + @pytest.fixture + def mock_objects_client(self): + """Mock for the Objects API client (becomes self.objects_client)""" + client = Mock() + client.base_url = "https://objects.example.com/api/v1/" + return client + + @pytest.fixture + def mock_objecttypes_client(self): + """Mock for the ObjectTypes API client (becomes self.objecttypes_client)""" + client = Mock() + client.base_url = "https://objecttypes.example.com/api/v1/" + return client + + @patch("objectsapiclient.services.build_zgw_client") + def test_is_healthy_success( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_response = Mock() + mock_response.status_code = 200 + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + is_healthy, message = service.is_healthy() + + # Verify the service was called with correct parameters + mock_objects_client.request.assert_called_once_with( + "head", + "https://objects.example.com/api/v1/objects", + ) + assert is_healthy is True + assert message == "" + + @patch("objectsapiclient.services.build_zgw_client") + def test_is_healthy_http_error( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_objects_client.request.side_effect = HTTPError("500 Server Error") + + service = ObjectsAPIService(config=mock_config) + + is_healthy, message = service.is_healthy() + + assert is_healthy is False + assert "500 Server Error" in message + + @patch("objectsapiclient.services.build_zgw_client") + def test_is_healthy_general_exception( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_objects_client.request.side_effect = ConnectionError("Connection timeout") + + service = ObjectsAPIService(config=mock_config) + + is_healthy, message = service.is_healthy() + + assert is_healthy is False + assert "Connection timeout" in message + + @patch("objectsapiclient.services.build_zgw_client") + def test_is_healthy_network_timeout( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_objects_client.request.side_effect = Timeout("Request timed out") + + service = ObjectsAPIService(config=mock_config) + + is_healthy, message = service.is_healthy() + + assert is_healthy is False + assert "Request timed out" in message + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_without_uuid( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_response = Mock() + mock_response.json.return_value = { + "results": [ + { + "url": "https://objects.example.com/api/v1/objects/123", + "uuid": "123", + "type": "https://objecttypes.example.com/api/v1/objecttypes/456", + "record": { + "index": 1, + "typeVersion": 1, + "data": {"name": "Test Object"}, + "geometry": None, + "startAt": "2023-01-01", + "endAt": None, + "registrationAt": "2023-01-01", + "correctionFor": None, + "correctedBy": None, + }, + } + ] + } + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + objects = service.get_objects() + + # Verify the service was called with correct parameters + mock_objects_client.request.assert_called_once_with( + "get", + "https://objects.example.com/api/v1/objects", + params=None, + ) + + # Verify response handling + mock_response.raise_for_status.assert_called_once() + assert len(objects) == 1 + assert objects[0].uuid == "123" + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_with_uuid( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + mock_response = Mock() + mock_response.json.return_value = { + "results": [ + { + "url": "https://objects.example.com/api/v1/objects/123", + "uuid": "123", + "type": "https://objecttypes.example.com/api/v1/objecttypes/456", + "record": { + "index": 1, + "typeVersion": 1, + "data": {"name": "Test Object with UUID"}, + "geometry": None, + "startAt": "2023-01-01", + "endAt": None, + "registrationAt": "2023-01-01", + "correctionFor": None, + "correctedBy": None, + }, + } + ] + } + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + test_uuid = "456" + objects = service.get_objects(object_type_uuid=test_uuid) + + # Verify the service was called with correct params including type filter + mock_objects_client.request.assert_called_once_with( + "get", + "https://objects.example.com/api/v1/objects", + params={"type": "https://objecttypes.example.com/api/v1/objecttypes/456/"}, + ) + + # Verify response handling + mock_response.raise_for_status.assert_called_once() + assert len(objects) == 1 + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_empty_results( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_response = Mock() + mock_response.json.return_value = {"results": []} + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + objects = service.get_objects() + + assert objects == [] + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_http_error( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_response = Mock() + mock_response.raise_for_status.side_effect = HTTPError("404 Not Found") + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + with pytest.raises(HTTPError, match="404 Not Found"): + service.get_objects() + + mock_objects_client.request.assert_called_once() + mock_response.raise_for_status.assert_called_once() + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_malformed_json( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_response = Mock() + # Response missing "results" key + mock_response.json.return_value = {} + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + objects = service.get_objects() + + # Should return empty list when 'results' key is missing + assert objects == [] + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_null_results( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_response = Mock() + mock_response.json.return_value = {"results": None} + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + objects = service.get_objects() + + # Should return empty list when results is None + assert objects == [] + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_with_uuid_http_error( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + mock_response = Mock() + mock_response.raise_for_status.side_effect = HTTPError("500 Server Error") + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + with pytest.raises(HTTPError, match="500 Server Error"): + service.get_objects(object_type_uuid="test-uuid") + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_object_types_success( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + # Mock response with object type data + mock_response = Mock() + mock_response.json.return_value = { + "results": [ + { + "url": "https://objecttypes.example.com/api/v1/objecttypes/123", + "uuid": "123", + "name": "Test Type", + "name_plural": "Test Types", + "description": "A test object type", + "data_classification": "open", + "maintainer_organization": "Test Org", + "maintainer_department": "Test Dept", + "contact_person": "John Doe", + "contact_email": "john@example.com", + "source": "test", + "update_frequency": "daily", + "provider_organization": "Provider", + "documentation_url": "https://docs.example.com", + "labels": {}, + "created_at": "2023-01-01T00:00:00Z", + "modified_at": "2023-01-02T00:00:00Z", + "allow_geometry": True, + "versions": [], + }, + { + "url": "https://objecttypes.example.com/api/v1/objecttypes/456", + "uuid": "456", + "name": "Another Type", + "name_plural": "Another Types", + "description": "Another test object type", + "data_classification": "intern", + "maintainer_organization": "Test Org 2", + "maintainer_department": "Test Dept 2", + "contact_person": "Jane Doe", + "contact_email": "jane@example.com", + "source": "test2", + "update_frequency": "weekly", + "provider_organization": "Provider 2", + "documentation_url": "https://docs2.example.com", + "labels": {}, + "created_at": "2023-02-01T00:00:00Z", + "modified_at": "2023-02-02T00:00:00Z", + "allow_geometry": False, + "versions": [], + }, + ] + } + mock_objecttypes_client.request.return_value = mock_response + + # Create service and call get_object_types + service = ObjectsAPIService(config=mock_config) + object_types = service.get_object_types() + + # Verify the object_types service was called with correct parameters + mock_objecttypes_client.request.assert_called_once_with( + method="get", + url="https://objecttypes.example.com/api/v1/objecttypes", + ) + + # Verify response handling + mock_response.raise_for_status.assert_called_once() + + # Verify we got the expected results + assert len(object_types) == 2 + assert object_types[0].uuid == "123" + assert object_types[0].name == "Test Type" + assert object_types[1].uuid == "456" + assert object_types[1].name == "Another Type" + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_object_types_empty_results( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + mock_response = Mock() + mock_response.json.return_value = {"results": []} + mock_objecttypes_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + object_types = service.get_object_types() + + mock_response.raise_for_status.assert_called_once() + assert object_types == [] + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_object_types_null_results( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + mock_response = Mock() + mock_response.json.return_value = {"results": None} + mock_objecttypes_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + object_types = service.get_object_types() + + assert object_types == [] + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_object_types_http_error( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + mock_response = Mock() + mock_response.raise_for_status.side_effect = HTTPError("404 Not Found") + mock_objecttypes_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + with pytest.raises(HTTPError, match="404 Not Found"): + service.get_object_types() + + mock_objecttypes_client.request.assert_called_once() + + @patch("objectsapiclient.services.build_zgw_client") + def test_object_type_uuid_to_url( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + service = ObjectsAPIService(config=mock_config) + + test_uuid = "123e4567-e89b-12d3-a456-426614174000" + url = service.object_type_uuid_to_url(test_uuid) + + assert ( + url + == "https://objecttypes.example.com/api/v1/objecttypes/123e4567-e89b-12d3-a456-426614174000/" + ) + + @patch("objectsapiclient.services.build_zgw_client") + @patch("objectsapiclient.services.ObjectsAPIServiceConfiguration.get_solo") + def test_client_initialization_with_explicit_config( + self, mock_get_solo, mock_build_client, mock_config + ): + service = ObjectsAPIService(config=mock_config) + + # get_solo should not be called when explicit config is provided + mock_get_solo.assert_not_called() + + # service should use the provided config + assert service.config == mock_config + + @patch("objectsapiclient.services.build_zgw_client") + @patch("objectsapiclient.services.ObjectsAPIServiceConfiguration.get_solo") + def test_client_initialization_without_config_uses_get_solo( + self, mock_get_solo, mock_build_client + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + objects_service = Mock() + objects_service.api_root = "https://objects.example.com/api/v1/" + mock_config.objects_api_client_config = objects_service + + object_types_service = Mock() + object_types_service.api_root = "https://objecttypes.example.com/api/v1/" + mock_config.objecttypes_api_client_config = object_types_service + + mock_get_solo.return_value = mock_config + + service = ObjectsAPIService() + + # get_solo should be called when no config is provided + mock_get_solo.assert_called_once() + + # ObjectsAPIService should use the config from get_solo + assert service.config == mock_config + + @patch("objectsapiclient.services.build_zgw_client") + @pytest.mark.parametrize( + "mock_objects_api, mock_object_type_api", + [ + (Mock(), None), + (None, Mock()), + (None, None), + ], + ) + def test_client_initialization_raises_when_api_services_not_configured( + self, + mock_build_client, + mock_objects_api, + mock_object_type_api, + ): + mock_config = Mock(spec=ObjectsAPIServiceConfiguration) + mock_config.objects_api_client_config = mock_objects_api + mock_config.objecttypes_api_client_config = mock_object_type_api + + with pytest.raises( + ImproperlyConfigured, + match="ObjectsAPIService cannot be instantiated without configurations", + ): + ObjectsAPIService(config=mock_config) + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_objects_validation_error( + self, mock_build_client, mock_config, mock_objects_client + ): + mock_build_client.return_value = mock_objects_client + mock_response = Mock() + mock_response.json.return_value = { + "results": [ + { + "url": "https://objects.example.com/api/v1/objects/123", + "uuid": "123", + "type": "https://objecttypes.example.com/api/v1/objecttypes/456", + "record": { + # Missing required fields: typeVersion, startAt + "data": {"name": "Invalid Object"}, + }, + } + ] + } + mock_objects_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + with pytest.raises(ObjectsAPIClientValidationError) as exc_info: + service.get_objects() + + # Verify the custom exception has the expected attributes + exception = exc_info.value + assert str(exception) == "API returned invalid object data" + assert exception.validation_error is not None + assert isinstance(exception.validation_error, ValidationError) + assert len(exception.errors) > 0 + assert exception.model_type.__name__ == "Object" + + @patch("objectsapiclient.services.build_zgw_client") + def test_get_object_types_validation_error( + self, + mock_build_client, + mock_config, + mock_objects_client, + mock_objecttypes_client, + ): + def build_client_side_effect(service): + if service == mock_config.objects_api_client_config: + return mock_objects_client + return mock_objecttypes_client + + mock_build_client.side_effect = build_client_side_effect + + mock_response = Mock() + mock_response.json.return_value = { + "results": [ + { + "uuid": "123", + # Missing required fields: name, name_plural + "description": "Invalid object type", + } + ] + } + mock_objecttypes_client.request.return_value = mock_response + + service = ObjectsAPIService(config=mock_config) + + with pytest.raises(ObjectsAPIClientValidationError) as exc_info: + service.get_object_types() + + # Verify the custom exception has the expected attributes + exception = exc_info.value + assert str(exception) == "API returned invalid object data" + assert exception.validation_error is not None + assert isinstance(exception.validation_error, ValidationError) + assert len(exception.errors) > 0 + assert exception.model_type.__name__ == "ObjectType" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..ec4b14f --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,96 @@ +from unittest.mock import Mock, patch + +from django.core.exceptions import ImproperlyConfigured + +import pytest +from requests.exceptions import HTTPError + +from objectsapiclient.utils import get_object_type_choices + + +class TestGetObjectTypeChoices: + """ + Tests for the get_object_type_choices utility function + """ + + @patch("objectsapiclient.services.ObjectsAPIService") + def test_get_object_type_choices_success(self, mock_client_class): + mock_client = Mock() + mock_client_class.return_value = mock_client + + # Create mock object types with names that need sorting + mock_type_1 = Mock() + mock_type_1.uuid = "uuid-zebra" + mock_type_1.name = "Zebra Type" + + mock_type_2 = Mock() + mock_type_2.uuid = "uuid-apple" + mock_type_2.name = "Apple Type" + + mock_type_3 = Mock() + mock_type_3.uuid = "uuid-banana" + mock_type_3.name = "Banana Type" + + mock_client.get_object_types.return_value = [ + mock_type_1, + mock_type_2, + mock_type_3, + ] + + choices = get_object_type_choices() + + # Verify ObjectsAPIService was instantiated + mock_client_class.assert_called_once() + mock_client.get_object_types.assert_called_once() + + # Verify results are sorted by name (second element in tuple) + assert choices == [ + ("uuid-apple", "Apple Type"), + ("uuid-banana", "Banana Type"), + ("uuid-zebra", "Zebra Type"), + ] + + @patch("objectsapiclient.services.ObjectsAPIService") + def test_get_object_type_choices_empty_results(self, mock_client_class): + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.get_object_types.return_value = [] + + choices = get_object_type_choices() + + assert choices == [] + + @patch("objectsapiclient.services.ObjectsAPIService") + def test_get_object_type_choices_single_result(self, mock_client_class): + mock_client = Mock() + mock_client_class.return_value = mock_client + + mock_type = Mock() + mock_type.uuid = "single-uuid" + mock_type.name = "Single Type" + + mock_client.get_object_types.return_value = [mock_type] + + choices = get_object_type_choices() + + assert choices == [("single-uuid", "Single Type")] + + @patch("objectsapiclient.services.ObjectsAPIService") + def test_get_object_type_choices_client_initialization_error( + self, mock_client_class + ): + mock_client_class.side_effect = ImproperlyConfigured( + "Objects API services not configured" + ) + + with pytest.raises(ImproperlyConfigured, match="Objects API services"): + get_object_type_choices() + + @patch("objectsapiclient.services.ObjectsAPIService") + def test_get_object_type_choices_api_error(self, mock_client_class): + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.get_object_types.side_effect = HTTPError("500 Server Error") + + with pytest.raises(HTTPError, match="500 Server Error"): + get_object_type_choices() diff --git a/tox.ini b/tox.ini index d95a1d0..fd69c1f 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{311,312,313}-django{42} ruff + check-migrations skip_missing_interpreters = true [gh-actions:env] @@ -29,3 +30,15 @@ skipsdist = True commands = ruff check . ruff format . + +[testenv:check-migrations] +setenv = + DJANGO_SETTINGS_MODULE=testapp.settings + PYTHONPATH={toxinidir} +extras = tests +deps = + Django~=4.2.0 + psycopg2-binary # required for loading zgw-consumers migrations +skipsdist = False +commands = + django-admin makemigrations --check --dry-run --no-input