diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 43c9494c3..916518335 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: POETRY_VIRTUALENVS_CREATE: false - name: Restore pre-commit cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index aa9ad1112..8247bbc3b 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -175,7 +175,7 @@ jobs: # using these changes throughout the rest of the build. If the base image # build wasn't changed, we don't use it and just rely on scheduled build. - name: Check if base image was changed by this branch - uses: dorny/paths-filter@v2 + uses: dorny/paths-filter@v3 id: changes with: filters: | @@ -273,18 +273,6 @@ jobs: matrix: platform: ["linux/amd64", "linux/arm64"] image: ["scripts", "webapp"] - env: - POSTGRES_USER: palace_user - POSTGRES_PASSWORD: test - POSTGRES_DB: palace_circulation - - services: - postgres: - image: postgres:12 - env: - POSTGRES_USER: ${{ env.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} - POSTGRES_DB: ${{ env.POSTGRES_DB }} steps: - uses: actions/checkout@v4 @@ -301,36 +289,23 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile - tags: test_image - load: true - target: ${{ matrix.image }} - cache-from: type=gha,scope=buildkit-${{ github.run_id }} - platforms: ${{ matrix.platform }} - build-args: | - BASE_IMAGE=${{ needs.docker-image-build.outputs.baseimage }} - - - name: Start container - run: > - docker run --rm --name test_container -d --platform ${{ matrix.platform }} - --network ${{job.services.postgres.network}} - -e SIMPLIFIED_PRODUCTION_DATABASE="postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@postgres:5432/${{ env.POSTGRES_DB }}" - test_image + - name: Build & Start container + run: docker compose up -d --build ${{ matrix.image }} + env: + BUILD_PLATFORM: ${{ matrix.platform }} + BUILD_CACHE_FROM: type=gha,scope=buildkit-${{ github.run_id }} + BUILD_BASE_IMAGE: ${{ needs.docker-image-build.outputs.baseimage }} - name: Run tests - run: ./docker/ci/test_${{ matrix.image }}.sh test_container + run: ./docker/ci/test_${{ matrix.image }}.sh ${{ matrix.image }} - name: Output logs if: failure() - run: docker logs test_container + run: docker logs circulation-${{ matrix.image }}-1 - name: Stop container if: always() - run: docker stop test_container + run: docker compose down docker-image-push: if: false # Disable temporarily diff --git a/README.md b/README.md index b41a78332..1efb80078 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Elasticsearch is no longer supported. We recommend that you run OpenSearch with docker using the following docker commands: ```sh -docker run --name opensearch -d --rm -p 9006:9200 -e "discovery.type=single-node" -e "plugins.security.disabled=true" "opensearchproject/opensearch:1" +docker run --name opensearch -d --rm -p 9200:9200 -e "discovery.type=single-node" -e "plugins.security.disabled=true" "opensearchproject/opensearch:1" docker exec opensearch opensearch-plugin -s install analysis-icu docker restart opensearch ``` @@ -165,6 +165,22 @@ To let the application know which database to use, set the `SIMPLIFIED_PRODUCTIO export SIMPLIFIED_PRODUCTION_DATABASE="postgresql://palace:test@localhost:5432/circ" ``` +#### Opensearch + +To let the application know which Opensearch instance to use, you can set the following environment variables: + +- `PALACE_SEARCH_URL`: The url of the Opensearch instance (**required**). +- `PALACE_SEARCH_INDEX_PREFIX`: The prefix to use for the Opensearch indices. The default is `circulation-works`. + This is useful if you want to use the same Opensearch instance for multiple CM (optional). +- `PALACE_SEARCH_TIMEOUT`: The timeout in seconds to use when connecting to the Opensearch instance. The default is `20` + (optional). +- `PALACE_SEARCH_MAXSIZE`: The maximum size of the connection pool to use when connecting to the Opensearch instance. + (optional). + +```sh +export PALACE_SEARCH_URL="http://localhost:9200" +``` + #### Storage Service The application optionally uses a s3 compatible storage service to store files. To configure the application to use @@ -656,7 +672,7 @@ If you already have elastic search or postgres running locally, you can run them following environment variables: - `SIMPLIFIED_TEST_DATABASE` -- `SIMPLIFIED_TEST_OPENSEARCH` +- `PALACE_TEST_SEARCH_URL` Make sure the ports and usernames are updated to reflect the local configuration. diff --git a/alembic/versions/20240124_993729d4bf97_migrate_metadata_services.py b/alembic/versions/20240124_993729d4bf97_migrate_metadata_services.py new file mode 100644 index 000000000..a2890b7bd --- /dev/null +++ b/alembic/versions/20240124_993729d4bf97_migrate_metadata_services.py @@ -0,0 +1,94 @@ +"""migrate metadata services + +Revision ID: 993729d4bf97 +Revises: 735bf6ced8b9 +Create Date: 2024-01-24 23:51:13.464107+00:00 + +""" +from alembic import op +from api.integration.registry.metadata import MetadataRegistry +from core.integration.base import HasLibraryIntegrationConfiguration +from core.migration.migrate_external_integration import ( + _migrate_external_integration, + _migrate_library_settings, + get_configuration_settings, + get_integrations, + get_library_for_integration, +) +from core.migration.util import pg_update_enum + +# revision identifiers, used by Alembic. +revision = "993729d4bf97" +down_revision = "735bf6ced8b9" +branch_labels = None +depends_on = None + +METADATA_GOAL = "METADATA_GOAL" +old_goals_enum = ["PATRON_AUTH_GOAL", "LICENSE_GOAL", "DISCOVERY_GOAL", "CATALOG_GOAL"] +new_goals_enum = old_goals_enum + [METADATA_GOAL] + + +def upgrade() -> None: + # Add the new enum value to our goals enum + pg_update_enum( + op, + "integration_configurations", + "goal", + "goals", + old_goals_enum, + new_goals_enum, + ) + + # Migrate the existing metadata services to integration configurations + connection = op.get_bind() + registry = MetadataRegistry() + integrations = get_integrations(connection, "metadata") + for integration in integrations: + _id, protocol, name = integration + protocol_class = registry[protocol] + + ( + settings_dict, + libraries_settings, + self_test_result, + ) = get_configuration_settings(connection, integration) + + updated_protocol = registry.get_protocol(protocol_class) + if updated_protocol is None: + raise RuntimeError(f"Unknown metadata service '{protocol}'") + integration_configuration_id = _migrate_external_integration( + connection, + integration.name, + updated_protocol, + protocol_class, + METADATA_GOAL, + settings_dict, + self_test_result, + ) + + integration_libraries = get_library_for_integration(connection, _id) + for library in integration_libraries: + if issubclass(protocol_class, HasLibraryIntegrationConfiguration): + _migrate_library_settings( + connection, + integration_configuration_id, + library.library_id, + libraries_settings[library.library_id], + protocol_class, + ) + else: + raise RuntimeError( + f"Protocol not expected to have library settings '{protocol}'" + ) + + +def downgrade() -> None: + # Remove the new enum value from our goals enum. + pg_update_enum( + op, + "integration_configurations", + "goal", + "goals", + new_goals_enum, + old_goals_enum, + ) diff --git a/api/admin/config.py b/api/admin/config.py index 1076e141e..548aaf5d1 100644 --- a/api/admin/config.py +++ b/api/admin/config.py @@ -16,7 +16,7 @@ class OperationalMode(str, Enum): class Configuration(LoggerMixin): APP_NAME = "E-kirjasto Collection Manager" PACKAGE_NAME = "@natlibfi/ekirjasto-circulation-admin" - PACKAGE_VERSION = "0.0.1" + PACKAGE_VERSION = "0.0.1-post.8" STATIC_ASSETS = { "admin_js": "circulation-admin.js", diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index ff613a1d5..62b814d73 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -13,7 +13,6 @@ def setup_admin_controllers(manager: CirculationManager): from api.admin.controller.admin_search import AdminSearchController from api.admin.controller.announcement_service import AnnouncementSettings from api.admin.controller.catalog_services import CatalogServicesController - from api.admin.controller.collection_self_tests import CollectionSelfTestsController from api.admin.controller.collection_settings import CollectionSettingsController from api.admin.controller.custom_lists import CustomListsController from api.admin.controller.dashboard import DashboardController @@ -27,26 +26,11 @@ def setup_admin_controllers(manager: CirculationManager): ) from api.admin.controller.lanes import LanesController from api.admin.controller.library_settings import LibrarySettingsController - from api.admin.controller.metadata_service_self_tests import ( - MetadataServiceSelfTestsController, - ) from api.admin.controller.metadata_services import MetadataServicesController from api.admin.controller.patron import PatronController - from api.admin.controller.patron_auth_service_self_tests import ( - PatronAuthServiceSelfTestsController, - ) from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.search_service_self_tests import ( - SearchServiceSelfTestsController, - ) - from api.admin.controller.self_tests import SelfTestsController - from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController - from api.admin.controller.sitewide_services import ( - SearchServicesController, - SitewideServicesController, - ) from api.admin.controller.sitewide_settings import ( SitewideConfigurationSettingsController, ) @@ -63,41 +47,25 @@ def setup_admin_controllers(manager: CirculationManager): manager.admin_custom_lists_controller = CustomListsController(manager) manager.admin_lanes_controller = LanesController(manager) manager.admin_dashboard_controller = DashboardController(manager) - manager.admin_settings_controller = SettingsController(manager) manager.admin_patron_controller = PatronController(manager) - manager.admin_self_tests_controller = SelfTestsController(manager) manager.admin_discovery_services_controller = DiscoveryServicesController(manager) manager.admin_discovery_service_library_registrations_controller = ( DiscoveryServiceLibraryRegistrationsController(manager) ) manager.admin_metadata_services_controller = MetadataServicesController(manager) - manager.admin_metadata_service_self_tests_controller = ( - MetadataServiceSelfTestsController(manager) - ) manager.admin_patron_auth_services_controller = PatronAuthServicesController( manager ) - manager.admin_patron_auth_service_self_tests_controller = ( - PatronAuthServiceSelfTestsController(manager._db) - ) manager.admin_collection_settings_controller = CollectionSettingsController(manager) - manager.admin_collection_self_tests_controller = CollectionSelfTestsController( - manager._db - ) manager.admin_sitewide_configuration_settings_controller = ( SitewideConfigurationSettingsController(manager) ) manager.admin_library_settings_controller = LibrarySettingsController(manager) manager.admin_individual_admin_settings_controller = ( - IndividualAdminSettingsController(manager) - ) - manager.admin_sitewide_services_controller = SitewideServicesController(manager) - manager.admin_search_service_self_tests_controller = ( - SearchServiceSelfTestsController(manager) + IndividualAdminSettingsController(manager._db) ) - manager.admin_search_services_controller = SearchServicesController(manager) manager.admin_catalog_services_controller = CatalogServicesController(manager) - manager.admin_announcement_service = AnnouncementSettings(manager) + manager.admin_announcement_service = AnnouncementSettings(manager._db) manager.admin_search_controller = AdminSearchController(manager) manager.admin_quicksight_controller = QuickSightController(manager) diff --git a/api/admin/controller/announcement_service.py b/api/admin/controller/announcement_service.py index 8ec18ac0a..78c6db022 100644 --- a/api/admin/controller/announcement_service.py +++ b/api/admin/controller/announcement_service.py @@ -4,18 +4,21 @@ from typing import Any import flask +from sqlalchemy.orm import Session from api.admin.announcement_list_validator import AnnouncementListValidator -from api.admin.controller.settings import SettingsController from api.config import Configuration from core.model.announcements import Announcement from core.problem_details import INVALID_INPUT from core.util.problem_detail import ProblemDetail, ProblemError -class AnnouncementSettings(SettingsController): +class AnnouncementSettings: """Controller that manages global announcements for all libraries""" + def __init__(self, db: Session) -> None: + self._db = db + def _action(self) -> Callable: method = flask.request.method.lower() return getattr(self, method) diff --git a/api/admin/controller/collection_self_tests.py b/api/admin/controller/collection_self_tests.py deleted file mode 100644 index 8cc53dcb1..000000000 --- a/api/admin/controller/collection_self_tests.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from flask import Response -from sqlalchemy.orm import Session - -from api.admin.controller.self_tests import IntegrationSelfTestsController -from api.circulation import CirculationApiType -from api.integration.registry.license_providers import LicenseProvidersRegistry -from core.integration.registry import IntegrationRegistry -from core.model import IntegrationConfiguration -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail - - -class CollectionSelfTestsController(IntegrationSelfTestsController[CirculationApiType]): - def __init__( - self, - db: Session, - registry: IntegrationRegistry[CirculationApiType] | None = None, - ): - registry = registry or LicenseProvidersRegistry() - super().__init__(db, registry) - - def process_collection_self_tests( - self, identifier: int | None - ) -> Response | ProblemDetail: - return self.process_self_tests(identifier) - - def run_self_tests( - self, integration: IntegrationConfiguration - ) -> dict[str, Any] | None: - protocol_class = self.get_protocol_class(integration) - if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): - test_result, _ = protocol_class.run_self_tests( - self.db, protocol_class, self.db, integration.collection - ) - return test_result - - return None diff --git a/api/admin/controller/collection_settings.py b/api/admin/controller/collection_settings.py index ee28ac21d..61dbe5e35 100644 --- a/api/admin/controller/collection_settings.py +++ b/api/admin/controller/collection_settings.py @@ -1,10 +1,14 @@ +from __future__ import annotations + from typing import Any import flask from flask import Response from api.admin.controller.base import AdminPermissionsControllerMixin -from api.admin.controller.integration_settings import IntegrationSettingsController +from api.admin.controller.integration_settings import ( + IntegrationSettingsSelfTestsController, +) from api.admin.form_data import ProcessFormData from api.admin.problem_details import ( CANNOT_DELETE_COLLECTION_WITH_CHILDREN, @@ -26,11 +30,13 @@ json_serializer, site_configuration_has_changed, ) +from core.selftest import HasSelfTests from core.util.problem_detail import ProblemDetail, ProblemError class CollectionSettingsController( - IntegrationSettingsController[CirculationApiType], AdminPermissionsControllerMixin + IntegrationSettingsSelfTestsController[CirculationApiType], + AdminPermissionsControllerMixin, ): def default_registry(self) -> IntegrationRegistry[CirculationApiType]: return LicenseProvidersRegistry() @@ -164,3 +170,20 @@ def process_delete(self, service_id: int) -> Response | ProblemDetail: # Flag the collection to be deleted by script in the background. collection.marked_for_deletion = True return Response("Deleted", 200) + + def process_collection_self_tests( + self, identifier: int | None + ) -> Response | ProblemDetail: + return self.process_self_tests(identifier) + + def run_self_tests( + self, integration: IntegrationConfiguration + ) -> dict[str, Any] | None: + protocol_class = self.get_protocol_class(integration.protocol) + if issubclass(protocol_class, HasSelfTests): + test_result, _ = protocol_class.run_self_tests( + self._db, protocol_class, self._db, integration.collection + ) + return test_result + + return None diff --git a/api/admin/controller/individual_admin_settings.py b/api/admin/controller/individual_admin_settings.py index 8e4297012..010046ffc 100644 --- a/api/admin/controller/individual_admin_settings.py +++ b/api/admin/controller/individual_admin_settings.py @@ -3,13 +3,16 @@ import flask from flask import Response from flask_babel import lazy_gettext as _ +from pydantic import EmailStr, parse_obj_as from sqlalchemy.exc import ProgrammingError +from sqlalchemy.orm import Session -from api.admin.controller.settings import SettingsController +from api.admin.controller.base import AdminPermissionsControllerMixin from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( ADMIN_AUTH_NOT_CONFIGURED, INCOMPLETE_CONFIGURATION, + INVALID_EMAIL, MISSING_ADMIN, MISSING_PGCRYPTO_EXTENSION, UNKNOWN_ROLE, @@ -19,7 +22,10 @@ from core.util.problem_detail import ProblemDetail -class IndividualAdminSettingsController(SettingsController): +class IndividualAdminSettingsController(AdminPermissionsControllerMixin): + def __init__(self, db: Session): + self._db = db + def process_individual_admins(self): if flask.request.method == "GET": return self.process_get() @@ -290,9 +296,12 @@ def validate_form_fields(self, email): _("The email field cannot be blank.") ) - email_error = self.validate_formats(email) - if email_error: - return email_error + try: + parse_obj_as(EmailStr, email) + except ValueError: + return INVALID_EMAIL.detailed( + _('"%(email)s" is not a valid email address.', email=email) + ) def validate_role_exists(self, role): if role.get("role") not in AdminRole.ROLES: diff --git a/api/admin/controller/integration_settings.py b/api/admin/controller/integration_settings.py index c8a93c8df..291db556f 100644 --- a/api/admin/controller/integration_settings.py +++ b/api/admin/controller/integration_settings.py @@ -10,7 +10,9 @@ from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, + FAILED_TO_RUN_SELF_TESTS, INTEGRATION_NAME_ALREADY_IN_USE, + MISSING_IDENTIFIER, MISSING_SERVICE, MISSING_SERVICE_NAME, NO_PROTOCOL_FOR_NEW_SERVICE, @@ -31,11 +33,13 @@ Library, create, get_one, + json_serializer, ) from core.problem_details import INTERNAL_SERVER_ERROR, INVALID_INPUT +from core.selftest import HasSelfTests from core.util.cache import memoize from core.util.log import LoggerMixin -from core.util.problem_detail import ProblemError +from core.util.problem_detail import ProblemDetail, ProblemError T = TypeVar("T", bound=HasIntegrationConfiguration[BaseSettings]) @@ -69,7 +73,7 @@ def default_registry(self) -> IntegrationRegistry[T]: @memoize(ttls=1800) def _cached_protocols(self) -> dict[str, dict[str, Any]]: - """Cached result for integration implementations""" + """Cached result for integration implementations.""" protocols = [] for name, api in self.registry: protocol = { @@ -99,23 +103,43 @@ def protocols(self) -> dict[str, dict[str, Any]]: def configured_service_info( self, service: IntegrationConfiguration ) -> dict[str, Any] | None: + """This is the default implementation for getting details about a configured integration. + It can be overridden by implementations that need to add additional information to the + service info dict that gets returned to the admin UI.""" + + if service.goal is None: + # We should never get here, since we only query for services with a goal, and goal + # is a required field, but for mypy and safety, we check for it anyway. + self.log.warning( + f"IntegrationConfiguration {service.name}({service.id}) has no goal set. Skipping." + ) + return None return { "id": service.id, "name": service.name, "protocol": service.protocol, "settings": service.settings_dict, + "goal": service.goal.value, } def configured_service_library_info( self, library_configuration: IntegrationLibraryConfiguration ) -> dict[str, Any] | None: + """This is the default implementation for getting details about a library integration for + a configured integration. It can be overridden by implementations that need to add + additional information to the `libraries` dict that gets returned to the admin UI. + """ library_info = {"short_name": library_configuration.library.short_name} library_info.update(library_configuration.settings_dict) return library_info @property def configured_services(self) -> list[dict[str, Any]]: - """Return a list of all currently configured services for the controller's goal.""" + """Return a list of all currently configured services for the controller's goal. + + If you need to add additional information to the service info dict that gets returned to the + admin UI, override the configured_service_info method instead of this one. + """ configured_services = [] for service in ( self._db.query(IntegrationConfiguration) @@ -147,7 +171,7 @@ def configured_services(self) -> list[dict[str, Any]]: return configured_services def get_existing_service( - self, service_id: int, name: str | None, protocol: str + self, service_id: int, name: str | None = None, protocol: str | None = None ) -> IntegrationConfiguration: """ Query for an existing service to edit. @@ -165,7 +189,7 @@ def get_existing_service( ) if service is None: raise ProblemError(MISSING_SERVICE) - if service.protocol != protocol: + if protocol is not None and service.protocol != protocol: raise ProblemError(CANNOT_CHANGE_PROTOCOL) if name is not None and service.name != name: service_with_name = get_one(self._db, IntegrationConfiguration, name=name) @@ -203,12 +227,31 @@ def create_new_service(self, name: str, protocol: str) -> IntegrationConfigurati return new_service def get_libraries_data(self, form_data: ImmutableMultiDict[str, str]) -> str | None: + """ + Get the library settings data from the form data sent in the request by the admin ui + and return it as a JSON string. + """ libraries_data = form_data.get("libraries", None, str) return libraries_data + def get_protocol_class(self, protocol: str | None) -> type[T]: + """ + Get the protocol class for the given protocol. Raises a ProblemError if the protocol + is unknown. + """ + if protocol is None or protocol not in self.registry: + self.log.warning(f"Unknown service protocol: {protocol}") + raise ProblemError(UNKNOWN_PROTOCOL) + return self.registry[protocol] + def get_service( self, form_data: ImmutableMultiDict[str, str] ) -> tuple[IntegrationConfiguration, str, int]: + """ + Get a service to edit or create, the protocol, and the response code to return to the + frontend. This method is used by both the process_post and process_delete methods to + get the service being operated on. + """ protocol = form_data.get("protocol", None, str) _id = form_data.get("id", None, int) name = form_data.get("name", None, str) @@ -216,9 +259,13 @@ def get_service( if protocol is None and _id is None: raise ProblemError(NO_PROTOCOL_FOR_NEW_SERVICE) - if protocol is None or protocol not in self.registry: - self.log.warning(f"Unknown service protocol: {protocol}") - raise ProblemError(UNKNOWN_PROTOCOL) + # Lookup the protocol class to make sure it exists + # this will raise a ProblemError if the protocol is unknown + self.get_protocol_class(protocol) + + # This should never happen, due to the call to get_protocol_class but + # mypy doesn't know that, so we make sure that protocol is not None before we use it. + assert protocol is not None if _id is not None: # Find an existing service to edit @@ -250,7 +297,8 @@ def create_library_settings( self, service: IntegrationConfiguration, short_name: str ) -> IntegrationLibraryConfiguration: """ - Create a new IntegrationLibraryConfiguration for the given IntegrationConfiguration and library. + Create a new IntegrationLibraryConfiguration for the given IntegrationConfiguration and library, + based on the library's short name. """ library = self.get_library(short_name) library_settings, _ = create( @@ -402,3 +450,96 @@ def delete_service(self, service_id: int) -> Response: raise ProblemError(problem_detail=MISSING_SERVICE) self._db.delete(integration) return Response("Deleted", 200) + + +class IntegrationSettingsSelfTestsController(IntegrationSettingsController[T], ABC): + @abstractmethod + def run_self_tests( + self, integration: IntegrationConfiguration + ) -> dict[str, Any] | None: + """ + Run self tests for the given integration. Returns a JSON-serializable dictionary + describing the results of the self-test run or None if there was an error running + the self tests. + """ + ... + + def configured_service_info( + self, service: IntegrationConfiguration + ) -> dict[str, Any] | None: + """ + Add the `self_test_results` key to the service info dict that gets returned to the + admin UI. This key contains the results of the last self test run for the service. + """ + service_info = super().configured_service_info(service) + if service_info is None: + return None + service_info["self_test_results"] = self.get_prior_test_results(service) + return service_info + + def get_prior_test_results( + self, integration: IntegrationConfiguration + ) -> dict[str, Any]: + """ + Get the results of the last self test run for the given integration. If the integration + doesn't have any self test results, return a dictionary with the `disabled` key set to + True. + + This method is useful to override if you need to add additional information to the + self test results dict that gets returned to the admin UI. + """ + protocol_class = self.get_protocol_class(integration.protocol) + if issubclass(protocol_class, HasSelfTests): + self_test_results = protocol_class.load_self_test_results(integration) # type: ignore[unreachable] + else: + self_test_results = dict( + exception=("Self tests are not supported for this integration."), + disabled=True, + ) + + return self_test_results + + def process_self_tests(self, identifier: int | None) -> Response | ProblemDetail: + """ + Generic request handler for GET and POST requests to the self tests endpoint. + This is often used by implementations that don't need to do any additional + processing of the request data. + """ + if not identifier: + return MISSING_IDENTIFIER + try: + if flask.request.method == "GET": + return self.self_tests_process_get(identifier) + else: + return self.self_tests_process_post(identifier) + except ProblemError as e: + return e.problem_detail + + def self_tests_process_get(self, identifier: int) -> Response: + """ + Return all the details for a given integration along with the self test results + for the integration as a JSON response. + + TODO: It doesn't seem like all the details for an integration should be contained + in the `self_test_results` key. But this is what the admin ui expects, so for now + we'll return everything in that key. + """ + integration = self.get_existing_service(identifier) + info = self.configured_service_info(integration) + return Response( + json_serializer({"self_test_results": info}), + status=200, + mimetype="application/json", + ) + + def self_tests_process_post(self, identifier: int) -> Response: + """ + Attempt to run the self tests for the given integration and return a response + indicating whether we were able to run the self tests or not. + """ + integration = self.get_existing_service(identifier) + results = self.run_self_tests(integration) + if results is not None: + return Response("Successfully ran new self tests", 200) + else: + raise ProblemError(problem_detail=FAILED_TO_RUN_SELF_TESTS) diff --git a/api/admin/controller/metadata_service_self_tests.py b/api/admin/controller/metadata_service_self_tests.py deleted file mode 100644 index eefda5496..000000000 --- a/api/admin/controller/metadata_service_self_tests.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Self-tests for metadata integrations.""" -from flask_babel import lazy_gettext as _ - -from api.admin.controller.metadata_services import MetadataServicesController -from api.admin.controller.self_tests import SelfTestsController -from core.model import ExternalIntegration - - -class MetadataServiceSelfTestsController( - MetadataServicesController, SelfTestsController -): - def __init__(self, manager): - super().__init__(manager) - self.type = _("metadata service") - - def process_metadata_service_self_tests(self, identifier): - return self._manage_self_tests(identifier) - - def look_up_by_id(self, id): - return self.look_up_service_by_id( - id, protocol=None, goal=ExternalIntegration.METADATA_GOAL - ) diff --git a/api/admin/controller/metadata_services.py b/api/admin/controller/metadata_services.py index 24e0003cf..cb4085056 100644 --- a/api/admin/controller/metadata_services.py +++ b/api/admin/controller/metadata_services.py @@ -1,126 +1,115 @@ +from typing import Any + import flask from flask import Response -from flask_babel import lazy_gettext as _ -from api.admin.controller.settings import SettingsController -from api.admin.problem_details import ( - INCOMPLETE_CONFIGURATION, - NO_PROTOCOL_FOR_NEW_SERVICE, +from api.admin.controller.base import AdminPermissionsControllerMixin +from api.admin.controller.integration_settings import ( + IntegrationSettingsSelfTestsController, ) -from api.novelist import NoveListAPI -from api.nyt import NYTBestSellerAPI -from core.model import ExternalIntegration, get_one -from core.util.problem_detail import ProblemDetail - - -class MetadataServicesController(SettingsController): - def __init__(self, manager): - super().__init__(manager) - self.provider_apis = [ - NYTBestSellerAPI, - NoveListAPI, - ] - - self.protocols = self._get_integration_protocols( - self.provider_apis, protocol_name_attr="PROTOCOL" - ) - self.goal = ExternalIntegration.METADATA_GOAL - self.type = _("metadata service") +from api.admin.form_data import ProcessFormData +from api.admin.problem_details import DUPLICATE_INTEGRATION +from api.integration.registry.metadata import MetadataRegistry +from api.metadata.base import MetadataServiceType +from core.integration.base import HasLibraryIntegrationConfiguration +from core.integration.registry import IntegrationRegistry +from core.model import ( + IntegrationConfiguration, + get_one, + json_serializer, + site_configuration_has_changed, +) +from core.selftest import HasSelfTests +from core.util.problem_detail import ProblemDetail, ProblemError + + +class MetadataServicesController( + IntegrationSettingsSelfTestsController[MetadataServiceType], + AdminPermissionsControllerMixin, +): + def create_new_service(self, name: str, protocol: str) -> IntegrationConfiguration: + impl_cls = self.registry[protocol] + if not impl_cls.multiple_services_allowed(): + # If the service doesn't allow multiple instances, check if one already exists + existing_service = get_one( + self._db, + IntegrationConfiguration, + goal=self.registry.goal, + protocol=protocol, + ) + if existing_service is not None: + raise ProblemError(DUPLICATE_INTEGRATION) + return super().create_new_service(name, protocol) - def process_metadata_services(self): + def default_registry(self) -> IntegrationRegistry[MetadataServiceType]: + return MetadataRegistry() + + def process_metadata_services(self) -> Response | ProblemDetail: self.require_system_admin() if flask.request.method == "GET": return self.process_get() else: return self.process_post() - def process_get(self): - metadata_services = self._get_integration_info(self.goal, self.protocols) - for service in metadata_services: - service_object = get_one( - self._db, - ExternalIntegration, - id=service.get("id"), - goal=ExternalIntegration.METADATA_GOAL, - ) - protocol_class, tuple = self.find_protocol_class(service_object) - service["self_test_results"] = self._get_prior_test_results( - service_object, protocol_class, *tuple - ) - - return dict( - metadata_services=metadata_services, - protocols=self.protocols, + def process_get(self) -> Response: + return Response( + json_serializer( + { + "metadata_services": self.configured_services, + "protocols": list(self.protocols.values()), + } + ), + status=200, + mimetype="application/json", ) - def find_protocol_class(self, integration): - if integration.protocol == ExternalIntegration.NYT: - return (NYTBestSellerAPI, (NYTBestSellerAPI.from_config, self._db)) - elif integration.protocol == ExternalIntegration.NOVELIST: - return (NoveListAPI, (NoveListAPI.from_config, self._db)) - raise NotImplementedError( - "No metadata self-test class for protocol %s" % integration.protocol - ) - - def process_post(self): - name = flask.request.form.get("name") - protocol = flask.request.form.get("protocol") - url = flask.request.form.get("url") - fields = {"name": name, "protocol": protocol, "url": url} - form_field_error = self.validate_form_fields(**fields) - if form_field_error: - return form_field_error - - id = flask.request.form.get("id") - is_new = False - if id: - # Find an existing service in order to edit it - service = self.look_up_service_by_id(id, protocol) - else: - service, is_new = self._create_integration( - self.protocols, protocol, self.goal - ) - - if isinstance(service, ProblemDetail): + def process_post(self) -> Response | ProblemDetail: + try: + form_data = flask.request.form + libraries_data = self.get_libraries_data(form_data) + metadata_service, protocol, response_code = self.get_service(form_data) + + # Update settings + impl_cls = self.registry[protocol] + settings_class = impl_cls.settings_class() + validated_settings = ProcessFormData.get_settings(settings_class, form_data) + metadata_service.settings_dict = validated_settings.dict() + + # Update library settings + if libraries_data and issubclass( + impl_cls, HasLibraryIntegrationConfiguration + ): + self.process_libraries( + metadata_service, libraries_data, impl_cls.library_settings_class() + ) + + # Trigger a site configuration change + site_configuration_has_changed(self._db) + + except ProblemError as e: self._db.rollback() - return service + return e.problem_detail - name_error = self.check_name_unique(service, name) - if name_error: - self._db.rollback() - return name_error + return Response(str(metadata_service.id), response_code) - protocol_error = self.set_protocols(service, protocol) - if protocol_error: - self._db.rollback() - return protocol_error + def process_delete(self, service_id: int) -> Response: + self.require_system_admin() + return self.delete_service(service_id) + + def run_self_tests( + self, integration: IntegrationConfiguration + ) -> dict[str, Any] | None: + protocol_class = self.get_protocol_class(integration.protocol) + if issubclass(protocol_class, HasSelfTests): + settings = protocol_class.settings_load(integration) + test_result, _ = protocol_class.run_self_tests( + self._db, protocol_class, self._db, settings + ) + return test_result - service.name = name + return None - if is_new: - return Response(str(service.id), 201) - else: - return Response(str(service.id), 200) - - def validate_form_fields(self, **fields): - """The 'name' and 'protocol' fields cannot be blank, and the protocol must - be selected from the list of recognized protocols. The URL must be valid.""" - name = fields.get("name") - protocol = fields.get("protocol") - url = fields.get("url") - - if not name: - return INCOMPLETE_CONFIGURATION - if not protocol: - return NO_PROTOCOL_FOR_NEW_SERVICE - - error = self.validate_protocol() - if error: - return error - - wrong_format = self.validate_formats() - if wrong_format: - return wrong_format - - def process_delete(self, service_id): - return self._delete_integration(service_id, self.goal) + def process_metadata_service_self_tests( + self, identifier: int | None + ) -> Response | ProblemDetail: + return self.process_self_tests(identifier) diff --git a/api/admin/controller/patron_auth_service_self_tests.py b/api/admin/controller/patron_auth_service_self_tests.py deleted file mode 100644 index 476456a59..000000000 --- a/api/admin/controller/patron_auth_service_self_tests.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from flask import Response -from sqlalchemy.orm import Session - -from api.admin.controller.self_tests import IntegrationSelfTestsController -from api.admin.problem_details import FAILED_TO_RUN_SELF_TESTS -from api.authentication.base import AuthenticationProviderType -from api.integration.registry.patron_auth import PatronAuthRegistry -from core.integration.registry import IntegrationRegistry -from core.model.integration import IntegrationConfiguration -from core.util.problem_detail import ProblemDetail, ProblemError - - -class PatronAuthServiceSelfTestsController( - IntegrationSelfTestsController[AuthenticationProviderType] -): - def __init__( - self, - db: Session, - registry: IntegrationRegistry[AuthenticationProviderType] | None = None, - ): - registry = registry or PatronAuthRegistry() - super().__init__(db, registry) - - def process_patron_auth_service_self_tests( - self, identifier: int | None - ) -> Response | ProblemDetail: - return self.process_self_tests(identifier) - - def get_prior_test_results( - self, - protocol_class: type[AuthenticationProviderType], - integration: IntegrationConfiguration, - ) -> dict[str, Any]: - # Find the first library associated with this service. - library_configuration = self.get_library_configuration(integration) - - if library_configuration is None: - return dict( - exception=( - "You must associate this service with at least one library " - "before you can run self tests for it." - ), - disabled=True, - ) - - return super().get_prior_test_results(protocol_class, integration) - - def run_self_tests(self, integration: IntegrationConfiguration) -> dict[str, Any]: - # If the auth service doesn't have at least one library associated with it, - # we can't run self tests. - library_configuration = self.get_library_configuration(integration) - if library_configuration is None: - raise ProblemError( - problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( - f"Failed to run self tests for {integration.name}, because it is not associated with any libraries." - ) - ) - - if not isinstance(integration.settings_dict, dict) or not isinstance( - library_configuration.settings_dict, dict - ): - raise ProblemError( - problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( - f"Failed to run self tests for {integration.name}, because its settings are not valid." - ) - ) - - protocol_class = self.get_protocol_class(integration) - settings = protocol_class.settings_load(integration) - library_settings = protocol_class.library_settings_load(library_configuration) - - value, _ = protocol_class.run_self_tests( - self.db, - None, - library_configuration.library_id, - integration.id, - settings, - library_settings, - ) - return value diff --git a/api/admin/controller/patron_auth_services.py b/api/admin/controller/patron_auth_services.py index 0e8dd595f..21b43f78f 100644 --- a/api/admin/controller/patron_auth_services.py +++ b/api/admin/controller/patron_auth_services.py @@ -1,9 +1,13 @@ +from __future__ import annotations + +from typing import Any + import flask from flask import Response from api.admin.controller.base import AdminPermissionsControllerMixin from api.admin.controller.integration_settings import ( - IntegrationSettingsController, + IntegrationSettingsSelfTestsController, UpdatedLibrarySettingsTuple, ) from api.admin.form_data import ProcessFormData @@ -14,7 +18,12 @@ from core.integration.goals import Goals from core.integration.registry import IntegrationRegistry from core.integration.settings import BaseSettings -from core.model import json_serializer, site_configuration_has_changed +from core.model import ( + IntegrationConfiguration, + IntegrationLibraryConfiguration, + json_serializer, + site_configuration_has_changed, +) from core.model.integration import ( IntegrationConfiguration, IntegrationLibraryConfiguration, @@ -23,7 +32,7 @@ class PatronAuthServicesController( - IntegrationSettingsController[AuthenticationProviderType], + IntegrationSettingsSelfTestsController[AuthenticationProviderType], AdminPermissionsControllerMixin, ): def default_registry(self) -> IntegrationRegistry[AuthenticationProviderType]: @@ -124,3 +133,68 @@ def process_delete(self, service_id: int) -> Response | ProblemDetail: except ProblemError as e: self._db.rollback() return e.problem_detail + + def process_patron_auth_service_self_tests( + self, identifier: int | None + ) -> Response | ProblemDetail: + return self.process_self_tests(identifier) + + def get_prior_test_results( + self, + integration: IntegrationConfiguration, + ) -> dict[str, Any]: + # Find the first library associated with this service. + library_configuration = self.get_library_configuration(integration) + + if library_configuration is None: + return dict( + exception=( + "You must associate this service with at least one library " + "before you can run self tests for it." + ), + disabled=True, + ) + + return super().get_prior_test_results(integration) + + def run_self_tests(self, integration: IntegrationConfiguration) -> dict[str, Any]: + # If the auth service doesn't have at least one library associated with it, + # we can't run self tests. + library_configuration = self.get_library_configuration(integration) + if library_configuration is None: + raise ProblemError( + problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( + f"Failed to run self tests for {integration.name}, because it is not associated with any libraries." + ) + ) + + if not isinstance(integration.settings_dict, dict) or not isinstance( + library_configuration.settings_dict, dict + ): + raise ProblemError( + problem_detail=FAILED_TO_RUN_SELF_TESTS.detailed( + f"Failed to run self tests for {integration.name}, because its settings are not valid." + ) + ) + + protocol_class = self.get_protocol_class(integration.protocol) + settings = protocol_class.settings_load(integration) + library_settings = protocol_class.library_settings_load(library_configuration) + + value, _ = protocol_class.run_self_tests( + self._db, + None, + library_configuration.library_id, + integration.id, + settings, + library_settings, + ) + return value + + @staticmethod + def get_library_configuration( + integration: IntegrationConfiguration, + ) -> IntegrationLibraryConfiguration | None: + if not integration.library_configurations: + return None + return integration.library_configurations[0] diff --git a/api/admin/controller/search_service_self_tests.py b/api/admin/controller/search_service_self_tests.py deleted file mode 100644 index 2ec4385f9..000000000 --- a/api/admin/controller/search_service_self_tests.py +++ /dev/null @@ -1,28 +0,0 @@ -from flask_babel import lazy_gettext as _ - -from api.admin.controller.self_tests import SelfTestsController -from core.external_search import ExternalSearchIndex -from core.model import ExternalIntegration - - -class SearchServiceSelfTestsController(SelfTestsController): - def __init__(self, manager): - super().__init__(manager) - self.type = _("search service") - - def process_search_service_self_tests(self, identifier): - return self._manage_self_tests(identifier) - - def _find_protocol_class(self, integration): - # There's only one possibility for search integrations. - return ExternalSearchIndex, ( - None, - self._db, - ) - - def look_up_by_id(self, identifier): - return self.look_up_service_by_id( - identifier, - ExternalIntegration.OPENSEARCH, - ExternalIntegration.SEARCH_GOAL, - ) diff --git a/api/admin/controller/self_tests.py b/api/admin/controller/self_tests.py deleted file mode 100644 index 239ff40ae..000000000 --- a/api/admin/controller/self_tests.py +++ /dev/null @@ -1,196 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any, Generic, TypeVar - -import flask -from flask import Response -from flask_babel import lazy_gettext as _ -from sqlalchemy.orm import Session - -from api.admin.controller.settings import SettingsController -from api.admin.problem_details import ( - FAILED_TO_RUN_SELF_TESTS, - MISSING_IDENTIFIER, - MISSING_SERVICE, - UNKNOWN_PROTOCOL, -) -from core.integration.base import HasIntegrationConfiguration -from core.integration.registry import IntegrationRegistry -from core.integration.settings import BaseSettings -from core.model import ( - IntegrationConfiguration, - IntegrationLibraryConfiguration, - get_one, - json_serializer, -) -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail, ProblemError - - -class SelfTestsController(SettingsController): - def _manage_self_tests(self, identifier): - """Generic request-processing method.""" - if not identifier: - return MISSING_IDENTIFIER - if flask.request.method == "GET": - return self.self_tests_process_get(identifier) - else: - return self.self_tests_process_post(identifier) - - def find_protocol_class(self, integration): - """Given an ExternalIntegration, find the class on which run_tests() - or prior_test_results() should be called, and any extra - arguments that should be passed into the call. - """ - if not hasattr(self, "_find_protocol_class"): - raise NotImplementedError() - protocol_class = self._find_protocol_class(integration) - if isinstance(protocol_class, tuple): - protocol_class, extra_arguments = protocol_class - else: - extra_arguments = () - return protocol_class, extra_arguments - - def get_info(self, integration): - protocol_class, ignore = self.find_protocol_class(integration) - [protocol] = self._get_integration_protocols([protocol_class]) - return dict( - id=integration.id, - name=integration.name, - protocol=protocol, - settings=protocol.get("settings"), - goal=integration.goal, - ) - - def run_tests(self, integration): - protocol_class, extra_arguments = self.find_protocol_class(integration) - value, results = protocol_class.run_self_tests(self._db, *extra_arguments) - return value - - def self_tests_process_get(self, identifier): - integration = self.look_up_by_id(identifier) - if isinstance(integration, ProblemDetail): - return integration - info = self.get_info(integration) - protocol_class, extra_arguments = self.find_protocol_class(integration) - info["self_test_results"] = self._get_prior_test_results( - integration, protocol_class, *extra_arguments - ) - return dict(self_test_results=info) - - def self_tests_process_post(self, identifier): - integration = self.look_up_by_id(identifier) - if isinstance(integration, ProblemDetail): - return integration - value = self.run_tests(integration) - if value and isinstance(value, ProblemDetail): - return value - elif value: - return Response(_("Successfully ran new self tests"), 200) - - return FAILED_TO_RUN_SELF_TESTS.detailed( - _("Failed to run self tests for this %(type)s.", type=self.type) - ) - - -T = TypeVar("T", bound=HasIntegrationConfiguration[BaseSettings]) - - -class IntegrationSelfTestsController(Generic[T], ABC): - def __init__( - self, - db: Session, - registry: IntegrationRegistry[T], - ): - self.db = db - self.registry = registry - - @abstractmethod - def run_self_tests( - self, integration: IntegrationConfiguration - ) -> dict[str, Any] | None: - ... - - def get_protocol_class(self, integration: IntegrationConfiguration) -> type[T]: - if not integration.protocol or integration.protocol not in self.registry: - raise ProblemError(problem_detail=UNKNOWN_PROTOCOL) - return self.registry[integration.protocol] - - def look_up_by_id(self, identifier: int) -> IntegrationConfiguration: - service = get_one( - self.db, - IntegrationConfiguration, - id=identifier, - goal=self.registry.goal, - ) - if not service: - raise (ProblemError(problem_detail=MISSING_SERVICE)) - return service - - @staticmethod - def get_info(integration: IntegrationConfiguration) -> dict[str, Any]: - info = dict( - id=integration.id, - name=integration.name, - protocol=integration.protocol, - goal=integration.goal, - settings=integration.settings_dict, - ) - return info - - @staticmethod - def get_library_configuration( - integration: IntegrationConfiguration, - ) -> IntegrationLibraryConfiguration | None: - if not integration.library_configurations: - return None - return integration.library_configurations[0] - - def get_prior_test_results( - self, protocol_class: type[T], integration: IntegrationConfiguration - ) -> dict[str, Any]: - if issubclass(protocol_class, HasSelfTestsIntegrationConfiguration): - self_test_results = protocol_class.load_self_test_results(integration) # type: ignore[unreachable] - else: - self_test_results = dict( - exception=("Self tests are not supported for this integration."), - disabled=True, - ) - - return self_test_results - - def process_self_tests(self, identifier: int | None) -> Response | ProblemDetail: - if not identifier: - return MISSING_IDENTIFIER - try: - if flask.request.method == "GET": - return self.self_tests_process_get(identifier) - else: - return self.self_tests_process_post(identifier) - except ProblemError as e: - return e.problem_detail - - def self_tests_process_get(self, identifier: int) -> Response: - integration = self.look_up_by_id(identifier) - info = self.get_info(integration) - protocol_class = self.get_protocol_class(integration) - - self_test_results = self.get_prior_test_results(protocol_class, integration) - - info["self_test_results"] = ( - self_test_results if self_test_results else "No results yet" - ) - return Response( - json_serializer({"self_test_results": info}), - status=200, - mimetype="application/json", - ) - - def self_tests_process_post(self, identifier: int) -> Response: - integration = self.look_up_by_id(identifier) - results = self.run_self_tests(integration) - if results is not None: - return Response("Successfully ran new self tests", 200) - else: - raise ProblemError(problem_detail=FAILED_TO_RUN_SELF_TESTS) diff --git a/api/admin/controller/settings.py b/api/admin/controller/settings.py index c9e6ceaa9..39e3eadcb 100644 --- a/api/admin/controller/settings.py +++ b/api/admin/controller/settings.py @@ -24,7 +24,6 @@ ) from api.admin.validator import Validator from api.controller.circulation_manager import CirculationManagerController -from core.external_search import ExternalSearchIndex from core.integration.base import ( HasChildIntegrationConfiguration, HasIntegrationConfiguration, @@ -406,11 +405,7 @@ def _get_prior_test_results(self, item, protocol_class=None, *extra_args): self_test_results = None try: - if self.type == "search service": - self_test_results = ExternalSearchIndex.prior_test_results( - self._db, None, self._db, item - ) - elif self.type == "metadata service" and protocol_class: + if self.type == "metadata service" and protocol_class: self_test_results = protocol_class.prior_test_results( self._db, *extra_args ) diff --git a/api/admin/controller/sitewide_services.py b/api/admin/controller/sitewide_services.py deleted file mode 100644 index 15f54cdec..000000000 --- a/api/admin/controller/sitewide_services.py +++ /dev/null @@ -1,127 +0,0 @@ -import flask -from flask import Response -from flask_babel import lazy_gettext as _ - -from api.admin.controller.settings import SettingsController -from api.admin.problem_details import ( - INCOMPLETE_CONFIGURATION, - MULTIPLE_SITEWIDE_SERVICES, - NO_PROTOCOL_FOR_NEW_SERVICE, - UNKNOWN_PROTOCOL, -) -from core.external_search import ExternalSearchIndex -from core.model import ExternalIntegration, get_one_or_create -from core.util.problem_detail import ProblemDetail - - -class SitewideServicesController(SettingsController): - def _manage_sitewide_service( - self, - goal, - provider_apis, - service_key_name, - multiple_sitewide_services_detail, - protocol_name_attr="NAME", - ): - protocols = self._get_integration_protocols( - provider_apis, protocol_name_attr=protocol_name_attr - ) - - self.require_system_admin() - if flask.request.method == "GET": - return self.process_get(protocols, goal, service_key_name) - else: - return self.process_post(protocols, goal, multiple_sitewide_services_detail) - - def process_get(self, protocols, goal, service_key_name): - services = self._get_integration_info(goal, protocols) - return { - service_key_name: services, - "protocols": protocols, - } - - def process_post(self, protocols, goal, multiple_sitewide_services_detail): - name = flask.request.form.get("name") - protocol = flask.request.form.get("protocol") - fields = {"name": name, "protocol": protocol} - form_field_error = self.validate_form_fields(protocols, **fields) - if form_field_error: - return form_field_error - - settings = protocols[0].get("settings") - wrong_format = self.validate_formats(settings) - if wrong_format: - return wrong_format - - is_new = False - id = flask.request.form.get("id") - - if id: - # Find an existing service in order to edit it - service = self.look_up_service_by_id(id, protocol, goal) - else: - if protocol: - service, is_new = get_one_or_create( - self._db, ExternalIntegration, protocol=protocol, goal=goal - ) - # There can only be one of each sitewide service. - if not is_new: - self._db.rollback() - return MULTIPLE_SITEWIDE_SERVICES.detailed( - multiple_sitewide_services_detail - ) - else: - return NO_PROTOCOL_FOR_NEW_SERVICE - - if isinstance(service, ProblemDetail): - self._db.rollback() - return service - - name_error = self.check_name_unique(service, name) - if name_error: - self._db.rollback() - return name_error - - protocol_error = self.set_protocols(service, protocol, protocols) - if protocol_error: - self._db.rollback() - return protocol_error - - service.name = name - - if is_new: - return Response(str(service.id), 201) - else: - return Response(str(service.id), 200) - - def validate_form_fields(self, protocols, **fields): - """The 'name' and 'protocol' fields cannot be blank, and the protocol must - be selected from the list of recognized protocols.""" - - name = fields.get("name") - protocol = fields.get("protocol") - - if not name: - return INCOMPLETE_CONFIGURATION - if protocol and protocol not in [p.get("name") for p in protocols]: - return UNKNOWN_PROTOCOL - - -class SearchServicesController(SitewideServicesController): - def __init__(self, manager): - super().__init__(manager) - self.type = _("search service") - - def process_services(self): - detail = _( - "You tried to create a new search service, but a search service is already configured." - ) - return self._manage_sitewide_service( - ExternalIntegration.SEARCH_GOAL, - [ExternalSearchIndex], - "search_services", - detail, - ) - - def process_delete(self, service_id): - return self._delete_integration(service_id, ExternalIntegration.SEARCH_GOAL) diff --git a/api/admin/controller/work_editor.py b/api/admin/controller/work_editor.py index 7363cdc7e..40b236f47 100644 --- a/api/admin/controller/work_editor.py +++ b/api/admin/controller/work_editor.py @@ -699,6 +699,6 @@ def custom_lists(self, identifier_type, identifier): # NOTE: This may not make a difference until the # works are actually re-indexed. for lane in affected_lanes: - lane.update_size(self._db, self.search_engine) + lane.update_size(self._db, search_engine=self.search_engine) return Response(str(_("Success")), 200) diff --git a/api/admin/routes.py b/api/admin/routes.py index d0539e50f..e4ec8d4df 100644 --- a/api/admin/routes.py +++ b/api/admin/routes.py @@ -8,6 +8,7 @@ from flask_pydantic_spec import Response as SpecResponse from api.admin.config import Configuration as AdminClientConfig +from api.admin.config import OperationalMode from api.admin.controller.custom_lists import CustomListsController from api.admin.dashboard_stats import generate_statistics from api.admin.model.dashboard_statistics import StatisticsResponse @@ -18,6 +19,7 @@ ) from api.admin.templates import admin_sign_in_again as sign_in_again_template from api.app import api_spec, app +from api.controller.static_file import StaticFileController from api.routes import allows_library, has_library, library_route from core.app_server import ensure_pydantic_after_problem_detail, returns_problem_detail from core.util.problem_detail import ProblemDetail, ProblemDetailModel, ProblemError @@ -445,8 +447,10 @@ def collection(collection_id): @requires_admin @requires_csrf_token def collection_self_tests(identifier): - return app.manager.admin_collection_self_tests_controller.process_collection_self_tests( - identifier + return ( + app.manager.admin_collection_settings_controller.process_collection_self_tests( + identifier + ) ) @@ -494,7 +498,7 @@ def patron_auth_service(service_id): @requires_admin @requires_csrf_token def patron_auth_self_tests(identifier): - return app.manager.admin_patron_auth_service_self_tests_controller.process_patron_auth_service_self_tests( + return app.manager.admin_patron_auth_services_controller.process_patron_auth_service_self_tests( identifier ) @@ -538,33 +542,7 @@ def metadata_service(service_id): @requires_admin @requires_csrf_token def metadata_service_self_tests(identifier): - return app.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( - identifier - ) - - -@app.route("/admin/search_services", methods=["GET", "POST"]) -@returns_json_or_response_or_problem_detail -@requires_admin -@requires_csrf_token -def search_services(): - return app.manager.admin_search_services_controller.process_services() - - -@app.route("/admin/search_service/", methods=["DELETE"]) -@returns_json_or_response_or_problem_detail -@requires_admin -@requires_csrf_token -def search_service(service_id): - return app.manager.admin_search_services_controller.process_delete(service_id) - - -@app.route("/admin/search_service_self_tests/", methods=["GET", "POST"]) -@returns_json_or_response_or_problem_detail -@requires_admin -@requires_csrf_token -def search_service_self_tests(identifier): - return app.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( + return app.manager.admin_metadata_services_controller.process_metadata_service_self_tests( identifier ) @@ -831,9 +809,11 @@ def admin_base(**kwargs): # This path is used only in debug mode to serve frontend assets. -@app.route("/admin/static/") -@returns_problem_detail -def admin_static_file(filename): - return app.manager.static_files.static_file( - AdminClientConfig.static_files_directory(), filename - ) +if AdminClientConfig.operational_mode() == OperationalMode.development: + + @app.route("/admin/static/") + @returns_problem_detail + def admin_static_file(filename): + return StaticFileController.static_file( + AdminClientConfig.static_files_directory(), filename + ) diff --git a/api/authentication/base.py b/api/authentication/base.py index 913fd8676..405fb3a32 100644 --- a/api/authentication/base.py +++ b/api/authentication/base.py @@ -13,7 +13,7 @@ from core.model import CirculationEvent, Library, Patron, get_one_or_create from core.model.hybrid import hybrid_property from core.model.integration import IntegrationConfiguration -from core.selftest import HasSelfTestsIntegrationConfiguration +from core.selftest import HasSelfTests from core.util.authentication_for_opds import OPDSAuthenticationFlow from core.util.datetime_helpers import utc_now from core.util.log import LoggerMixin @@ -37,7 +37,7 @@ class AuthProviderLibrarySettings(BaseSettings): class AuthenticationProvider( OPDSAuthenticationFlow, HasLibraryIntegrationConfiguration[SettingsType, LibrarySettingsType], - HasSelfTestsIntegrationConfiguration, + HasSelfTests, LoggerMixin, ABC, ): diff --git a/api/authentication/basic.py b/api/authentication/basic.py index b4075d468..c24add7c0 100644 --- a/api/authentication/basic.py +++ b/api/authentication/basic.py @@ -191,13 +191,13 @@ class BasicAuthProviderLibrarySettings(AuthProviderLibrarySettings): library_identifier_restriction_type: LibraryIdentifierRestriction = FormField( LibraryIdentifierRestriction.NONE, form=ConfigurationFormItem( - label="Library Identifier Restriction", + label="Library Identifier Restriction Type", type=ConfigurationFormItemType.SELECT, description="When multiple libraries share an ILS, a person may be able to " - "authenticate with the ILS but not be considered a patron of " + "authenticate with the ILS, but not be considered a patron of " "this library. This setting contains the rule for determining " "whether an identifier is valid for this specific library.

" - "If this setting it set to 'No Restriction' then the values for " + "If this setting is set to 'No Restriction', then the values for " "Library Identifier Field and Library Identifier " "Restriction will not be used.", options={ diff --git a/api/circulation_manager.py b/api/circulation_manager.py index 15ce86370..b7244ae07 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -24,7 +24,6 @@ from api.controller.patron_auth_token import PatronAuthTokenController from api.controller.playtime_entries import PlaytimeEntriesController from api.controller.profile import ProfileController -from api.controller.static_file import StaticFileController from api.controller.urn_lookup import URNLookupController from api.controller.work import WorkController from api.custom_index import CustomIndexView @@ -34,7 +33,6 @@ from api.problem_details import * from api.saml.controller import SAMLController from core.app_server import ApplicationVersionController, load_facets_from_request -from core.external_search import ExternalSearchIndex from core.feed.annotator.circulation import ( CirculationManagerAnnotator, LibraryAnnotator, @@ -50,7 +48,6 @@ from api.admin.controller.admin_search import AdminSearchController from api.admin.controller.announcement_service import AnnouncementSettings from api.admin.controller.catalog_services import CatalogServicesController - from api.admin.controller.collection_self_tests import CollectionSelfTestsController from api.admin.controller.collection_settings import CollectionSettingsController from api.admin.controller.custom_lists import CustomListsController from api.admin.controller.dashboard import DashboardController @@ -64,27 +61,12 @@ ) from api.admin.controller.lanes import LanesController from api.admin.controller.library_settings import LibrarySettingsController - from api.admin.controller.metadata_service_self_tests import ( - MetadataServiceSelfTestsController, - ) from api.admin.controller.metadata_services import MetadataServicesController from api.admin.controller.patron import PatronController - from api.admin.controller.patron_auth_service_self_tests import ( - PatronAuthServiceSelfTestsController, - ) from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.controller.quicksight import QuickSightController from api.admin.controller.reset_password import ResetPasswordController - from api.admin.controller.search_service_self_tests import ( - SearchServiceSelfTestsController, - ) - from api.admin.controller.self_tests import SelfTestsController - from api.admin.controller.settings import SettingsController from api.admin.controller.sign_in import SignInController - from api.admin.controller.sitewide_services import ( - SearchServicesController, - SitewideServicesController, - ) from api.admin.controller.sitewide_settings import ( SitewideConfigurationSettingsController, ) @@ -107,7 +89,6 @@ class CirculationManager(LoggerMixin): patron_devices: DeviceTokensController version: ApplicationVersionController odl_notification_controller: ODLNotificationController - static_files: StaticFileController playtime_entries: PlaytimeEntriesController # Admin controllers @@ -119,23 +100,19 @@ class CirculationManager(LoggerMixin): admin_custom_lists_controller: CustomListsController admin_lanes_controller: LanesController admin_dashboard_controller: DashboardController - admin_settings_controller: SettingsController admin_patron_controller: PatronController - admin_self_tests_controller: SelfTestsController admin_discovery_services_controller: DiscoveryServicesController - admin_discovery_service_library_registrations_controller: DiscoveryServiceLibraryRegistrationsController + admin_discovery_service_library_registrations_controller: ( + DiscoveryServiceLibraryRegistrationsController + ) admin_metadata_services_controller: MetadataServicesController - admin_metadata_service_self_tests_controller: MetadataServiceSelfTestsController admin_patron_auth_services_controller: PatronAuthServicesController - admin_patron_auth_service_self_tests_controller: PatronAuthServiceSelfTestsController admin_collection_settings_controller: CollectionSettingsController - admin_collection_self_tests_controller: CollectionSelfTestsController - admin_sitewide_configuration_settings_controller: SitewideConfigurationSettingsController + admin_sitewide_configuration_settings_controller: ( + SitewideConfigurationSettingsController + ) admin_library_settings_controller: LibrarySettingsController admin_individual_admin_settings_controller: IndividualAdminSettingsController - admin_sitewide_services_controller: SitewideServicesController - admin_search_service_self_tests_controller: SearchServiceSelfTestsController - admin_search_services_controller: SearchServicesController admin_catalog_services_controller: CatalogServicesController admin_announcement_service: AnnouncementSettings admin_search_controller: AdminSearchController @@ -151,6 +128,7 @@ def __init__( self._db = _db self.services = services self.analytics = services.analytics.analytics() + self.external_search = services.search.index() self.site_configuration_last_update = ( Configuration.site_configuration_last_update(self._db, timeout=0) ) @@ -213,12 +191,9 @@ def load_settings(self): ): # Populate caches Library.cache_warm(self._db, lambda: libraries) - ConfigurationSetting.cache_warm(self._db) self.auth = Authenticator(self._db, libraries, self.analytics) - self.setup_external_search() - # Finland self.setup_opensearch_analytics_search() @@ -255,14 +230,9 @@ def get_domain(url): url = url.strip() if url == "*": return url - ( - scheme, - netloc, - path, - parameters, - query, - fragment, - ) = urllib.parse.urlparse(url) + scheme, netloc, path, parameters, query, fragment = urllib.parse.urlparse( + url + ) if scheme and netloc: return scheme + "://" + netloc else: @@ -321,28 +291,6 @@ def setup_opensearch_analytics_search(self): self.opensearch_analytics_search_initialization_exception = e return self._opensearch_analytics_search - @property - def external_search(self): - """Retrieve or create a connection to the search interface. - - This is created lazily so that a failure to connect only - affects feeds that depend on the search engine, not the whole - circulation manager. - """ - if not self._external_search: - self.setup_external_search() - return self._external_search - - def setup_external_search(self): - try: - self._external_search = self.setup_search() - self.external_search_initialization_exception = None - except Exception as e: - self.log.error("Exception initializing search engine: %s", e) - self._external_search = None - self.external_search_initialization_exception = e - return self._external_search - def log_lanes(self, lanelist=None, level=0): """Output information about the lane layout.""" lanelist = lanelist or self.top_level_lane.sublanes @@ -351,14 +299,6 @@ def log_lanes(self, lanelist=None, level=0): if lane.sublanes: self.log_lanes(lane.sublanes, level + 1) - def setup_search(self): - """Set up a search client.""" - search = ExternalSearchIndex(self._db) - if not search: - self.log.warn("No external search server configured.") - return None - return search - def setup_circulation(self, library, analytics): """Set up the Circulation object.""" return CirculationAPI(self._db, library, analytics=analytics) @@ -381,7 +321,6 @@ def setup_one_time_controllers(self): self.patron_devices = DeviceTokensController(self) self.version = ApplicationVersionController() self.odl_notification_controller = ODLNotificationController(self) - self.static_files = StaticFileController(self) self.patron_auth_token = PatronAuthTokenController(self) self.catalog_descriptions = CatalogDescriptionsController(self) self.playtime_entries = PlaytimeEntriesController(self) diff --git a/api/controller/loan.py b/api/controller/loan.py index 9d8c33f68..f2e1e1291 100644 --- a/api/controller/loan.py +++ b/api/controller/loan.py @@ -145,11 +145,12 @@ def borrow(self, identifier_type, identifier, mechanism_id=None): # At this point we have either a loan or a hold. If a loan, serve # a feed that tells the patron how to fulfill the loan. If a hold, # serve a feed that talks about the hold. - response_kwargs = {} - if is_new: - response_kwargs["status"] = 201 - else: - response_kwargs["status"] = 200 + # We also need to drill in the Accept header, so that it eventually + # gets sent to core.feed.opds.BaseOPDSFeed.entry_as_response + response_kwargs = { + "status": 201 if is_new else 200, + "mime_types": flask.request.accept_mimetypes, + } return OPDSAcquisitionFeed.single_entry_loans_feed( self.circulation, loan_or_hold, **response_kwargs ) diff --git a/api/controller/static_file.py b/api/controller/static_file.py index 4016f0966..2b446ac04 100644 --- a/api/controller/static_file.py +++ b/api/controller/static_file.py @@ -1,27 +1,9 @@ from __future__ import annotations -import os - import flask -from api.config import Configuration -from api.controller.circulation_manager import CirculationManagerController -from core.model import ConfigurationSetting - - -class StaticFileController(CirculationManagerController): - def static_file(self, directory, filename): - max_age = ConfigurationSetting.sitewide( - self._db, Configuration.STATIC_FILE_CACHE_TIME - ).int_value - return flask.send_from_directory(directory, filename, max_age=max_age) - def image(self, filename): - directory = os.path.join( - os.path.abspath(os.path.dirname(__file__)), - "..", - "..", - "resources", - "images", - ) - return self.static_file(directory, filename) +class StaticFileController: + @staticmethod + def static_file(directory, filename): + return flask.send_from_directory(directory, filename) diff --git a/api/firstbook2.py b/api/firstbook2.py deleted file mode 100644 index 22a3870f7..000000000 --- a/api/firstbook2.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -import re -import time -from re import Pattern - -import jwt -import requests -from flask_babel import lazy_gettext as _ -from pydantic import HttpUrl - -from api.authentication.base import PatronData -from api.authentication.basic import ( - BasicAuthenticationProvider, - BasicAuthProviderLibrarySettings, - BasicAuthProviderSettings, -) -from api.circulation_exceptions import RemoteInitiatedServerError -from core.integration.settings import ConfigurationFormItem, FormField -from core.model import Patron - - -class FirstBookAuthSettings(BasicAuthProviderSettings): - url: HttpUrl = FormField( - "https://ebooksprod.firstbook.org/api/", - form=ConfigurationFormItem( - label=_("URL"), - description=_("The URL for the First Book authentication service."), - required=True, - ), - ) - password: str = FormField( - ..., - form=ConfigurationFormItem( - label=_("Key"), - description=_("The key for the First Book authentication service."), - ), - ) - # Server-side validation happens before the identifier - # is converted to uppercase, which means lowercase characters - # are valid. - identifier_regular_expression: Pattern = FormField( - re.compile(r"^[A-Za-z0-9@]+$"), - form=ConfigurationFormItem( - label="Identifier Regular Expression", - description="A patron's identifier will be immediately rejected if it doesn't match this " - "regular expression.", - weight=10, - ), - ) - password_regular_expression: Pattern | None = FormField( - re.compile(r"^[0-9]+$"), - form=ConfigurationFormItem( - label="Password Regular Expression", - description="A patron's password will be immediately rejected if it doesn't match this " - "regular expression.", - weight=10, - ), - ) - - -class FirstBookAuthenticationAPI( - BasicAuthenticationProvider[FirstBookAuthSettings, BasicAuthProviderLibrarySettings] -): - @classmethod - def label(cls) -> str: - return "First Book" - - @classmethod - def description(cls) -> str: - return ( - "An authentication service for Open eBooks that authenticates using access codes and " - "PINs. (This is the new version.)" - ) - - @classmethod - def settings_class(cls) -> type[FirstBookAuthSettings]: - return FirstBookAuthSettings - - @classmethod - def library_settings_class(cls) -> type[BasicAuthProviderLibrarySettings]: - return BasicAuthProviderLibrarySettings - - @property - def login_button_image(self) -> str | None: - return "FirstBookLoginButton280.png" - - # The algorithm used to sign JWTs. - ALGORITHM = "HS256" - - # If FirstBook sends this message it means they accepted the - # patron's credentials. - SUCCESS_MESSAGE = "Valid Code Pin Pair" - - def __init__( - self, - library_id: int, - integration_id: int, - settings: FirstBookAuthSettings, - library_settings: BasicAuthProviderLibrarySettings, - analytics=None, - ): - super().__init__( - library_id, integration_id, settings, library_settings, analytics - ) - self.root = settings.url - self.secret = settings.password - - def remote_authenticate( - self, username: str | None, password: str | None - ) -> PatronData | None: - # All FirstBook credentials are in upper-case. - if username is None or username == "": - return None - - username = username.upper() - - # If they fail a PIN test, there is no authenticated patron. - if not self.remote_pin_test(username, password): - return None - - # FirstBook keeps track of absolutely no information - # about the patron other than the permanent ID, - # which is also the authorization identifier. - return PatronData( - permanent_id=username, - authorization_identifier=username, - ) - - def remote_patron_lookup( - self, patron_or_patrondata: PatronData | Patron - ) -> PatronData | None: - if isinstance(patron_or_patrondata, PatronData): - return patron_or_patrondata - - return None - - # End implementation of BasicAuthenticationProvider abstract methods. - - def remote_pin_test(self, barcode, pin): - jwt = self.jwt(barcode, pin) - url = self.root + jwt - try: - response = self.request(url) - except requests.exceptions.ConnectionError as e: - raise RemoteInitiatedServerError(str(e), self.__class__.__name__) - content = response.content.decode("utf8") - if response.status_code != 200: - msg = "Got unexpected response code %d. Content: %s" % ( - response.status_code, - content, - ) - raise RemoteInitiatedServerError(msg, self.__class__.__name__) - if self.SUCCESS_MESSAGE in content: - return True - return False - - def jwt(self, barcode, pin): - """Create and sign a JWT with the payload expected by the - First Book API. - """ - now = int(time.time()) - payload = dict( - barcode=barcode, - pin=pin, - iat=now, - ) - return jwt.encode(payload, self.secret, algorithm=self.ALGORITHM) - - def request(self, url): - """Make an HTTP request. - - Defined solely so it can be overridden in the mock. - """ - return requests.get(url) diff --git a/api/integration/registry/metadata.py b/api/integration/registry/metadata.py new file mode 100644 index 000000000..f0a2e6398 --- /dev/null +++ b/api/integration/registry/metadata.py @@ -0,0 +1,13 @@ +from api.metadata.base import MetadataServiceType +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI +from core.integration.goals import Goals +from core.integration.registry import IntegrationRegistry + + +class MetadataRegistry(IntegrationRegistry[MetadataServiceType]): + def __init__(self) -> None: + super().__init__(Goals.METADATA_GOAL) + + self.register(NYTBestSellerAPI, canonical="New York Times") + self.register(NoveListAPI, canonical="NoveList Select") diff --git a/api/integration/registry/patron_auth.py b/api/integration/registry/patron_auth.py index b92aa5850..a6ce2ec16 100644 --- a/api/integration/registry/patron_auth.py +++ b/api/integration/registry/patron_auth.py @@ -12,7 +12,6 @@ class PatronAuthRegistry(IntegrationRegistry["AuthenticationProviderType"]): def __init__(self) -> None: super().__init__(Goals.PATRON_AUTH_GOAL) - from api.firstbook2 import FirstBookAuthenticationAPI from api.kansas_patron import KansasAuthenticationAPI from api.millenium_patron import MilleniumPatronAPI from api.saml.provider import SAMLWebSSOAuthenticationProvider @@ -27,7 +26,6 @@ def __init__(self) -> None: ) self.register(MilleniumPatronAPI, canonical="api.millenium_patron") self.register(SIP2AuthenticationProvider, canonical="api.sip") - self.register(FirstBookAuthenticationAPI, canonical="api.firstbook2") self.register(KansasAuthenticationAPI, canonical="api.kansas_patron") self.register(SAMLWebSSOAuthenticationProvider, canonical="api.saml.provider") self.register( diff --git a/api/lanes.py b/api/lanes.py index 4479426e4..98db7ab3a 100644 --- a/api/lanes.py +++ b/api/lanes.py @@ -2,7 +2,8 @@ import core.classifier as genres from api.config import CannotLoadConfiguration, Configuration -from api.novelist import NoveListAPI +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI from core import classifier from core.classifier import Classifier, GenreData, fiction_genres, nonfiction_genres from core.lane import ( @@ -12,16 +13,7 @@ Lane, WorkList, ) -from core.model import ( - Contributor, - DataSource, - Edition, - ExternalIntegration, - Library, - Session, - create, - get_one, -) +from core.model import Contributor, DataSource, Edition, Library, Session, create from core.util import LanguageCodes @@ -275,16 +267,13 @@ def create_lanes_for_large_collection(_db, library, languages, priority=0): adult_common_args = dict(common_args) adult_common_args["audiences"] = ADULT - include_best_sellers = False nyt_data_source = DataSource.lookup(_db, DataSource.NYT) - nyt_integration = get_one( - _db, - ExternalIntegration, - goal=ExternalIntegration.METADATA_GOAL, - protocol=ExternalIntegration.NYT, - ) - if nyt_integration: + try: + NYTBestSellerAPI.from_config(_db) include_best_sellers = True + except CannotLoadConfiguration: + # No NYT Best Seller integration is configured. + include_best_sellers = False sublanes = [] if include_best_sellers: diff --git a/api/metadata/__init__.py b/api/metadata/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/metadata/base.py b/api/metadata/base.py new file mode 100644 index 000000000..be15944cd --- /dev/null +++ b/api/metadata/base.py @@ -0,0 +1,50 @@ +import functools +from abc import ABC, abstractmethod +from typing import Any, TypeVar + +from sqlalchemy.orm import Session + +from core.integration.base import ( + HasIntegrationConfiguration, + HasLibraryIntegrationConfiguration, +) +from core.integration.settings import BaseSettings + + +class MetadataServiceSettings(BaseSettings): + ... + + +SettingsType = TypeVar("SettingsType", bound=MetadataServiceSettings, covariant=True) + + +class MetadataService( + HasIntegrationConfiguration[SettingsType], + ABC, +): + @classmethod + def protocol_details(cls, db: Session) -> dict[str, Any]: + details = super().protocol_details(db) + details["sitewide"] = not issubclass(cls, HasLibraryIntegrationConfiguration) + return details + + @classmethod + @functools.cache + def protocols(cls) -> list[str]: + from api.integration.registry.metadata import MetadataRegistry + + registry = MetadataRegistry() + protocols = registry.get_protocols(cls) + + if not protocols: + raise RuntimeError(f"No protocols found for {cls.__name__}") + + return protocols + + @classmethod + @abstractmethod + def multiple_services_allowed(cls) -> bool: + ... + + +MetadataServiceType = MetadataService[MetadataServiceSettings] diff --git a/api/novelist.py b/api/metadata/novelist.py similarity index 79% rename from api/novelist.py rename to api/metadata/novelist.py index c910fa45c..a8197036d 100644 --- a/api/novelist.py +++ b/api/metadata/novelist.py @@ -1,15 +1,24 @@ +import datetime import json import logging +import sys import urllib.error import urllib.parse import urllib.request from collections import Counter +from collections.abc import Mapping +from typing import Any -from flask_babel import lazy_gettext as _ +from requests import Response +from sqlalchemy.engine import Row from sqlalchemy.orm import aliased from sqlalchemy.sql import and_, join, or_, select +from api.metadata.base import MetadataService, MetadataServiceSettings from core.config import CannotLoadConfiguration +from core.integration.base import HasLibraryIntegrationConfiguration +from core.integration.goals import Goals +from core.integration.settings import BaseSettings, ConfigurationFormItem, FormField from core.metadata_layer import ( ContributorData, IdentifierData, @@ -24,9 +33,10 @@ DataSource, Edition, Equivalency, - ExternalIntegration, Hyperlink, Identifier, + IntegrationConfiguration, + Library, LicensePool, Measurement, Representation, @@ -35,32 +45,48 @@ ) from core.util import TitleProcessor from core.util.http import HTTP +from core.util.log import LoggerMixin +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self -class NoveListAPI: - PROTOCOL = ExternalIntegration.NOVELIST - NAME = _("Novelist API") +class NoveListApiSettings(MetadataServiceSettings): + """Settings for the NoveList API""" + + username: str = FormField( + ..., + form=ConfigurationFormItem( + label="Profile", + ), + ) + password: str = FormField( + ..., + form=ConfigurationFormItem( + label="Password", + ), + ) + + +class NoveListApiLibrarySettings(BaseSettings): + ... + + +class NoveListAPI( + MetadataService[NoveListApiSettings], + HasLibraryIntegrationConfiguration[NoveListApiSettings, NoveListApiLibrarySettings], + LoggerMixin, +): # Hardcoded authentication key used as a Header for calling the NoveList # Collections API. It identifies the client, and lets NoveList know that # SimplyE is making the requests. + # TODO: This is leftover from before the fork with SimplyE. We should probably + # get a new API key for Palace and use that instead. AUTHORIZED_IDENTIFIER = "62521fa1-bdbb-4939-84aa-aee2a52c8d59" - SETTINGS = [ - {"key": ExternalIntegration.USERNAME, "label": _("Profile"), "required": True}, - {"key": ExternalIntegration.PASSWORD, "label": _("Password"), "required": True}, - ] - - # Different libraries may have different NoveList integrations - # on the same circulation manager. - SITEWIDE = False - - IS_CONFIGURED = None - _configuration_library_id = None - - log = logging.getLogger("NoveList API") version = "2.2" - NO_ISBN_EQUIVALENCY = "No clear ISBN equivalency: %r" # While the NoveList API doesn't require parameters to be passed via URL, @@ -74,66 +100,81 @@ class NoveListAPI: AUTH_PARAMS = "&profile=%(profile)s&password=%(password)s" MAX_REPRESENTATION_AGE = 7 * 24 * 60 * 60 # one week - currentQueryIdentifier = None - medium_to_book_format_type_values = { Edition.BOOK_MEDIUM: "EBook", Edition.AUDIO_MEDIUM: "Audiobook", } @classmethod - def from_config(cls, library): - profile, password = cls.values(library) - if not (profile and password): + def from_config(cls, library: Library) -> Self: + settings = cls.values(library) + if not settings: raise CannotLoadConfiguration( "No NoveList integration configured for library (%s)." % library.short_name ) _db = Session.object_session(library) - return cls(_db, profile, password) + return cls(_db, settings) @classmethod - def values(cls, library): + def integration(cls, library: Library) -> IntegrationConfiguration | None: _db = Session.object_session(library) - - integration = ExternalIntegration.lookup( - _db, - ExternalIntegration.NOVELIST, - ExternalIntegration.METADATA_GOAL, - library=library, + query = select(IntegrationConfiguration).where( + IntegrationConfiguration.goal == Goals.METADATA_GOAL, + IntegrationConfiguration.libraries.contains(library), + IntegrationConfiguration.protocol.in_(cls.protocols()), ) + return _db.execute(query).scalar_one_or_none() + + @classmethod + def values(cls, library: Library) -> NoveListApiSettings | None: + integration = cls.integration(library) if not integration: - return (None, None) + return None + + return cls.settings_load(integration) - profile = integration.username - password = integration.password - return (profile, password) + @classmethod + def is_configured(cls, library: Library) -> bool: + integration = cls.integration(library) + return integration is not None + + @classmethod + def label(cls) -> str: + return "Novelist API" + + @classmethod + def description(cls) -> str: + return "" @classmethod - def is_configured(cls, library): - if cls.IS_CONFIGURED is None or library.id != cls._configuration_library_id: - profile, password = cls.values(library) - cls.IS_CONFIGURED = bool(profile and password) - cls._configuration_library_id = library.id - return cls.IS_CONFIGURED - - def __init__(self, _db, profile, password): + def settings_class(cls) -> type[NoveListApiSettings]: + return NoveListApiSettings + + @classmethod + def library_settings_class(cls) -> type[NoveListApiLibrarySettings]: + return NoveListApiLibrarySettings + + @classmethod + def multiple_services_allowed(cls) -> bool: + return True + + def __init__(self, _db: Session, settings: NoveListApiSettings) -> None: self._db = _db - self.profile = profile - self.password = password + self.profile = settings.username + self.password = settings.password @property - def source(self): - return DataSource.lookup(self._db, DataSource.NOVELIST) + def source(self) -> DataSource: + return DataSource.lookup(self._db, DataSource.NOVELIST) # type: ignore[no-any-return] - def lookup_equivalent_isbns(self, identifier): + def lookup_equivalent_isbns(self, identifier: Identifier) -> Metadata | None: """Finds NoveList data for all ISBNs equivalent to an identifier. :return: Metadata object or None """ - lookup_metadata = [] license_sources = DataSource.license_sources_for(self._db, identifier) # Find strong ISBN equivalents. @@ -179,14 +220,17 @@ def lookup_equivalent_isbns(self, identifier): best_metadata, confidence = self.choose_best_metadata( lookup_metadata, identifier ) - if best_metadata: - if round(confidence, 2) < 0.5: - self.log.warning(self.NO_ISBN_EQUIVALENCY, identifier) - return None - return metadata + if best_metadata is None or confidence is None: + return None + + if round(confidence, 2) < 0.5: + self.log.warning(self.NO_ISBN_EQUIVALENCY, identifier) + return None + + return best_metadata @classmethod - def _confirm_same_identifier(self, metadata_objects): + def _confirm_same_identifier(self, metadata_objects: list[Metadata]) -> bool: """Ensures that all metadata objects have the same NoveList ID""" novelist_ids = { @@ -194,7 +238,9 @@ def _confirm_same_identifier(self, metadata_objects): } return len(novelist_ids) == 1 - def choose_best_metadata(self, metadata_objects, identifier): + def choose_best_metadata( + self, metadata_objects: list[Metadata], identifier: Identifier + ) -> tuple[Metadata, float] | tuple[None, None]: """Chooses the most likely book metadata from a list of Metadata objects Given several Metadata objects with different NoveList IDs, this @@ -208,7 +254,7 @@ def choose_best_metadata(self, metadata_objects, identifier): # One or more of the equivalents did not return the same NoveList work self.log.warning("%r has inaccurate ISBN equivalents", identifier) - counter = Counter() + counter: Counter[Identifier] = Counter() for metadata in metadata_objects: counter[metadata.primary_identifier] += 1 @@ -225,7 +271,7 @@ def choose_best_metadata(self, metadata_objects, identifier): ] return target_metadata[0], confidence - def lookup(self, identifier, **kwargs): + def lookup(self, identifier: Identifier, **kwargs: Any) -> Metadata | None: """Requests NoveList metadata for a particular identifier :param kwargs: Keyword arguments passed into Representation.post(). @@ -236,9 +282,13 @@ def lookup(self, identifier, **kwargs): if identifier.type != Identifier.ISBN: return self.lookup_equivalent_isbns(identifier) + isbn = identifier.identifier + if isbn is None: + return None + params = dict( ClientIdentifier=client_identifier, - ISBN=identifier.identifier, + ISBN=isbn, version=self.version, profile=self.profile, password=self.password, @@ -251,7 +301,7 @@ def lookup(self, identifier, **kwargs): # We want to make an HTTP request for `url` but cache the # result under `scrubbed_url`. Define a 'URL normalization' # function that always returns `scrubbed_url`. - def normalized_url(original): + def normalized_url(original: str) -> str: return scrubbed_url representation, from_cache = Representation.post( @@ -272,22 +322,21 @@ def normalized_url(original): return self.lookup_info_to_metadata(representation) @classmethod - def review_response(cls, response): + def review_response(cls, response: tuple[int, dict[str, str], bytes]) -> None: """Performs NoveList-specific error review of the request response""" status_code, headers, content = response if status_code == 403: raise Exception("Invalid NoveList credentials") if content.startswith(b'"Missing'): raise Exception("Invalid NoveList parameters: %s" % content.decode("utf-8")) - return response @classmethod - def scrubbed_url(cls, params): + def scrubbed_url(cls, params: Mapping[str, str]) -> str: """Removes authentication details from cached Representation.url""" return cls.build_query_url(params, include_auth=False) @classmethod - def _scrub_subtitle(cls, subtitle): + def _scrub_subtitle(cls, subtitle: str | None) -> str | None: """Removes common NoveList subtitle annoyances""" if subtitle: subtitle = subtitle.replace("[electronic resource]", "") @@ -296,7 +345,9 @@ def _scrub_subtitle(cls, subtitle): return subtitle @classmethod - def build_query_url(cls, params, include_auth=True): + def build_query_url( + cls, params: Mapping[str, str], include_auth: bool = True + ) -> str: """Builds a unique and url-encoded query endpoint""" url = cls.QUERY_ENDPOINT if include_auth: @@ -307,7 +358,9 @@ def build_query_url(cls, params, include_auth=True): urlencoded_params[name] = urllib.parse.quote(value) return url % urlencoded_params - def lookup_info_to_metadata(self, lookup_representation): + def lookup_info_to_metadata( + self, lookup_representation: Response + ) -> Metadata | None: """Transforms a NoveList JSON representation into a Metadata object""" if not lookup_representation.content: @@ -415,10 +468,15 @@ def lookup_info_to_metadata(self, lookup_representation): or metadata.subtitle or metadata.recommendations ): - metadata = None + return None return metadata - def get_series_information(self, metadata, series_info, book_info): + def get_series_information( + self, + metadata: Metadata, + series_info: Mapping[str, Any] | None, + book_info: Mapping[str, Any], + ) -> tuple[Metadata, str]: """Returns metadata object with series info and optimal title key""" title_key = "main_title" @@ -456,10 +514,10 @@ def get_series_information(self, metadata, series_info, book_info): return metadata, title_key - def _extract_isbns(self, book_info): + def _extract_isbns(self, book_info: Mapping[str, Any]) -> list[IdentifierData]: isbns = [] - synonymous_ids = book_info.get("manifestations") + synonymous_ids = book_info.get("manifestations", []) for synonymous_id in synonymous_ids: isbn = synonymous_id.get("ISBN") if isbn: @@ -468,18 +526,20 @@ def _extract_isbns(self, book_info): return isbns - def get_recommendations(self, metadata, recommendations_info): + def get_recommendations( + self, metadata: Metadata, recommendations_info: Mapping[str, Any] | None + ) -> Metadata: if not recommendations_info: return metadata - related_books = recommendations_info.get("titles") + related_books = recommendations_info.get("titles", []) related_books = [b for b in related_books if b.get("is_held_locally")] if related_books: for book_info in related_books: metadata.recommendations += self._extract_isbns(book_info) return metadata - def get_items_from_query(self, library): + def get_items_from_query(self, library: Library) -> list[dict[str, str]]: """Gets identifiers and its related title, medium, and authors from the database. Keeps track of the current 'ISBN' identifier and current item object that @@ -515,18 +575,18 @@ def get_items_from_query(self, library): ) .select_from( join(LicensePool, i1, i1.id == LicensePool.identifier_id) - .join(Equivalency, i1.id == Equivalency.input_id, LEFT_OUTER_JOIN) + .join(Equivalency, i1.id == Equivalency.input_id, LEFT_OUTER_JOIN) # type: ignore[arg-type] .join(i2, Equivalency.output_id == i2.id, LEFT_OUTER_JOIN) .join( - Edition, + Edition, # type: ignore[arg-type] or_( Edition.primary_identifier_id == i1.id, Edition.primary_identifier_id == i2.id, ), ) - .join(Contribution, Edition.id == Contribution.edition_id) - .join(Contributor, Contribution.contributor_id == Contributor.id) - .join(DataSource, DataSource.id == LicensePool.data_source_id) + .join(Contribution, Edition.id == Contribution.edition_id) # type: ignore[arg-type] + .join(Contributor, Contribution.contributor_id == Contributor.id) # type: ignore[arg-type] + .join(DataSource, DataSource.id == LicensePool.data_source_id) # type: ignore[arg-type] ) .where( and_( @@ -541,9 +601,9 @@ def get_items_from_query(self, library): result = self._db.execute(isbnQuery) items = [] - newItem = None - existingItem = None - currentIdentifier = None + newItem: dict[str, str] | None = None + existingItem: dict[str, str] | None = None + currentIdentifier: str | None = None # Loop through the query result. There's a need to keep track of the # previously processed object and the currently processed object because @@ -571,7 +631,14 @@ def get_items_from_query(self, library): return items - def create_item_object(self, object, currentIdentifier, existingItem): + def create_item_object( + self, + object: Row + | tuple[str, str, str, str, str, datetime.date, str, str, str] + | None, + currentIdentifier: str | None, + existingItem: dict[str, str] | None, + ) -> tuple[str | None, dict[str, str] | None, dict[str, str] | None, bool]: """Returns a new item if the current identifier that was processed is not the same as the new object's ISBN being processed. If the new object's ISBN matches the current identifier, the previous object's @@ -654,10 +721,10 @@ def create_item_object(self, object, currentIdentifier, existingItem): return (isbn, existingItem, newItem, addItem) - def put_items_novelist(self, library): + def put_items_novelist(self, library: Library) -> dict[str, Any] | None: items = self.get_items_from_query(library) - content = None + content: dict[str, Any] | None = None if items: data = json.dumps(self.make_novelist_data_object(items)) response = self.put( @@ -679,34 +746,16 @@ def put_items_novelist(self, library): return content - def make_novelist_data_object(self, items): + def make_novelist_data_object(self, items: list[dict[str, str]]) -> dict[str, Any]: return { "customer": f"{self.profile}:{self.password}", "records": items, } - def put(self, url, headers, **kwargs): - data = kwargs.get("data") - if "data" in kwargs: - del kwargs["data"] + def put(self, url: str, headers: Mapping[str, str], **kwargs: Any) -> Response: + data = kwargs.pop("data", None) # This might take a very long time -- disable the normal # timeout. kwargs["timeout"] = None response = HTTP.put_with_timeout(url, data, headers=headers, **kwargs) return response - - -class MockNoveListAPI(NoveListAPI): - def __init__(self, _db, *args, **kwargs): - self._db = _db - self.responses = [] - - def setup_method(self, *args): - self.responses = self.responses + list(args) - - def lookup(self, identifier): - if not self.responses: - return [] - response = self.responses[0] - self.responses = self.responses[1:] - return response diff --git a/api/nyt.py b/api/metadata/nyt.py similarity index 73% rename from api/nyt.py rename to api/metadata/nyt.py index 3fb22e39a..020ac08c4 100644 --- a/api/nyt.py +++ b/api/metadata/nyt.py @@ -1,25 +1,48 @@ +from __future__ import annotations + +from core.selftest import HasSelfTests, SelfTestResult + """Interface to the New York Times APIs.""" import json -import logging -from datetime import datetime, timedelta +import sys +from collections.abc import Generator +from datetime import date, datetime, timedelta +from typing import Any from dateutil import tz -from flask_babel import lazy_gettext as _ +from sqlalchemy import select from sqlalchemy.orm.session import Session from api.config import CannotLoadConfiguration, IntegrationException +from api.metadata.base import MetadataService, MetadataServiceSettings from core.external_list import TitleFromExternalList +from core.integration.goals import Goals +from core.integration.settings import ConfigurationFormItem, FormField from core.metadata_layer import ContributorData, IdentifierData, Metadata from core.model import ( CustomList, DataSource, Edition, - ExternalIntegration, Identifier, + IntegrationConfiguration, Representation, get_one_or_create, ) -from core.selftest import HasSelfTests +from core.util.log import LoggerMixin + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + + +class NytBestSellerApiSettings(MetadataServiceSettings): + password: str = FormField( + ..., + form=ConfigurationFormItem( + label="API key", + ), + ) class NYTAPI: @@ -36,7 +59,7 @@ class NYTAPI: TIME_ZONE = tz.gettz("America/New York") @classmethod - def parse_datetime(cls, d): + def parse_datetime(cls, d: str) -> datetime: """Used to parse the publication date of a NYT best-seller list. We take midnight Eastern time to be the publication time. @@ -44,7 +67,7 @@ def parse_datetime(cls, d): return datetime.strptime(d, cls.DATE_FORMAT).replace(tzinfo=cls.TIME_ZONE) @classmethod - def parse_date(cls, d): + def parse_date(cls, d: str) -> date: """Used to parse the publication date of a book. We don't know the timezone here, so the date will end up being @@ -53,23 +76,16 @@ def parse_date(cls, d): return cls.parse_datetime(d).date() @classmethod - def date_string(cls, d): + def date_string(cls, d: date) -> str: return d.strftime(cls.DATE_FORMAT) -class NYTBestSellerAPI(NYTAPI, HasSelfTests): - PROTOCOL = ExternalIntegration.NYT - GOAL = ExternalIntegration.METADATA_GOAL - NAME = _("NYT Best Seller API") - CARDINALITY = 1 - - SETTINGS = [ - {"key": ExternalIntegration.PASSWORD, "label": _("API key"), "required": True}, - ] - - # An NYT integration is shared by all libraries in a circulation manager. - SITEWIDE = True - +class NYTBestSellerAPI( + NYTAPI, + MetadataService[NytBestSellerApiSettings], + HasSelfTests, + LoggerMixin, +): BASE_URL = "http://api.nytimes.com/svc/books/v3/lists" LIST_NAMES_URL = BASE_URL + "/names.json" @@ -80,37 +96,54 @@ class NYTBestSellerAPI(NYTAPI, HasSelfTests): HISTORICAL_LIST_MAX_AGE = timedelta(days=365) @classmethod - def from_config(cls, _db, **kwargs): - integration = cls.external_integration(_db) + def label(cls) -> str: + return "NYT Best Seller API" + + @classmethod + def description(cls) -> str: + return "" + + @classmethod + def settings_class(cls) -> type[NytBestSellerApiSettings]: + return NytBestSellerApiSettings + + @classmethod + def integration(cls, _db: Session) -> IntegrationConfiguration | None: + query = select(IntegrationConfiguration).where( + IntegrationConfiguration.goal == Goals.METADATA_GOAL, + IntegrationConfiguration.protocol.in_(cls.protocols()), + ) + return _db.execute(query).scalar_one_or_none() + + @classmethod + def from_config(cls, _db: Session) -> Self: + integration = cls.integration(_db) if not integration: - message = "No ExternalIntegration found for the NYT." + message = "No Integration found for the NYT." raise CannotLoadConfiguration(message) - return cls(_db, api_key=integration.password, **kwargs) + settings = cls.settings_load(integration) + return cls(_db, settings=settings) - def __init__(self, _db, api_key=None, do_get=None): - self.log = logging.getLogger("NYT API") + def __init__(self, _db: Session, settings: NytBestSellerApiSettings) -> None: self._db = _db - if not api_key: - raise CannotLoadConfiguration("No NYT API key is specified") - self.api_key = api_key - self.do_get = do_get or Representation.simple_http_get + self.api_key = settings.password @classmethod - def external_integration(cls, _db): - return ExternalIntegration.lookup( - _db, ExternalIntegration.NYT, ExternalIntegration.METADATA_GOAL - ) + def do_get( + cls, url: str, headers: dict[str, str], **kwargs: Any + ) -> tuple[int, dict[str, str], bytes]: + return Representation.simple_http_get(url, headers, **kwargs) - def _run_self_tests(self, _db): - yield self.run_test("Getting list of best-seller lists", self.list_of_lists) + @classmethod + def multiple_services_allowed(cls) -> bool: + return False - @property - def source(self): - return DataSource.lookup(_db, DataSource.NYT) + def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: + yield self.run_test("Getting list of best-seller lists", self.list_of_lists) - def request(self, path, identifier=None, max_age=LIST_MAX_AGE): + def request(self, path: str, max_age: timedelta = LIST_MAX_AGE) -> dict[str, Any]: if not path.startswith(self.BASE_URL): if not path.startswith("/"): path = "/" + path @@ -133,7 +166,7 @@ def request(self, path, identifier=None, max_age=LIST_MAX_AGE): if status == 200: # Everything's fine. content = json.loads(representation.content) - return content + return content # type: ignore[no-any-return] diagnostic = "Response from {} was: {!r}".format( url, @@ -150,25 +183,30 @@ def request(self, path, identifier=None, max_age=LIST_MAX_AGE): "Unknown API error (status %s)" % status, diagnostic ) - def list_of_lists(self, max_age=LIST_OF_LISTS_MAX_AGE): + def list_of_lists(self, max_age: timedelta = LIST_MAX_AGE) -> dict[str, Any]: return self.request(self.LIST_NAMES_URL, max_age=max_age) - def list_info(self, list_name): + def list_info(self, list_name: str) -> dict[str, Any]: list_of_lists = self.list_of_lists() list_info = [ x for x in list_of_lists["results"] if x["list_name_encoded"] == list_name ] if not list_info: raise ValueError("No such list: %s" % list_name) - return list_info[0] + return list_info[0] # type: ignore[no-any-return] - def best_seller_list(self, list_info, date=None): + def best_seller_list(self, list_info: str | dict[str, Any]) -> NYTBestSellerList: """Create (but don't update) a NYTBestSellerList object.""" if isinstance(list_info, str): list_info = self.list_info(list_info) return NYTBestSellerList(list_info) - def update(self, list, date=None, max_age=LIST_MAX_AGE): + def update( + self, + list: NYTBestSellerList, + date: date | None = None, + max_age: timedelta = LIST_MAX_AGE, + ) -> None: """Update the given list with data from the given date.""" name = list.foreign_identifier url = self.LIST_URL % name @@ -178,15 +216,15 @@ def update(self, list, date=None, max_age=LIST_MAX_AGE): data = self.request(url, max_age=max_age) list.update(data) - def fill_in_history(self, list): + def fill_in_history(self, list: NYTBestSellerList) -> None: """Update the given list with current and historical data.""" for date in list.all_dates: self.update(list, date, self.HISTORICAL_LIST_MAX_AGE) self._db.commit() -class NYTBestSellerList(list): - def __init__(self, list_info): +class NYTBestSellerList(list["NYTBestSellerListTitle"], LoggerMixin): + def __init__(self, list_info: dict[str, Any]) -> None: self.name = list_info["display_name"] self.created = NYTAPI.parse_datetime(list_info["oldest_published_date"]) self.updated = NYTAPI.parse_datetime(list_info["newest_published_date"]) @@ -196,11 +234,10 @@ def __init__(self, list_info): elif list_info["updated"] == "MONTHLY": frequency = 30 self.frequency = timedelta(frequency) - self.items_by_isbn = dict() - self.log = logging.getLogger("NYT Best-seller list %s" % self.name) + self.items_by_isbn: dict[str, NYTBestSellerListTitle] = dict() @property - def medium(self): + def medium(self) -> str | None: """What medium are the books on this list? Lists like "Audio Fiction" contain audiobooks; all others @@ -216,7 +253,7 @@ def medium(self): return Edition.BOOK_MEDIUM @property - def all_dates(self): + def all_dates(self) -> Generator[datetime, None, None]: """Yield a list of estimated dates when new editions of this list were probably published. """ @@ -230,7 +267,7 @@ def all_dates(self): # We overshot the end date. yield end - def update(self, json_data): + def update(self, json_data: dict[str, Any]) -> None: """Update the list with information from the given JSON structure.""" for li_data in json_data.get("results", []): try: @@ -244,11 +281,13 @@ def update(self, json_data): self.items_by_isbn[key] = item self.append(item) # self.log.debug("Newly seen ISBN: %r, %s", key, len(self)) - except ValueError as e: + except ValueError: # Should only happen when the book has no identifier, which... # should never happen. self.log.error("No identifier for %r", li_data) item = None + + if item is None: continue # This is the date the *best-seller list* was published, @@ -262,7 +301,7 @@ def update(self, json_data): ): item.most_recent_appearance = list_date - def to_customlist(self, _db): + def to_customlist(self, _db: Session) -> CustomList: """Turn this NYTBestSeller list into a CustomList object.""" data_source = DataSource.lookup(_db, DataSource.NYT) l, was_new = get_one_or_create( @@ -279,7 +318,7 @@ def to_customlist(self, _db): self.update_custom_list(l) return l - def update_custom_list(self, custom_list): + def update_custom_list(self, custom_list: CustomList) -> None: """Make sure the given CustomList's CustomListEntries reflect the current state of the NYTBestSeller list. """ @@ -293,10 +332,9 @@ def update_custom_list(self, custom_list): class NYTBestSellerListTitle(TitleFromExternalList): - def __init__(self, data, medium): - data = data + def __init__(self, data: dict[str, Any], medium: str | None) -> None: try: - bestsellers_date = NYTAPI.parse_datetime(data.get("bestsellers_date")) + bestsellers_date = NYTAPI.parse_datetime(data.get("bestsellers_date")) # type: ignore[arg-type] first_appearance = bestsellers_date most_recent_appearance = bestsellers_date except ValueError as e: @@ -306,7 +344,7 @@ def __init__(self, data, medium): try: # This is the date the _book_ was published, not the date # the _bestseller list_ was published. - published_date = NYTAPI.parse_date(data.get("published_date")) + published_date = NYTAPI.parse_date(data.get("published_date")) # type: ignore[arg-type] except ValueError as e: published_date = None diff --git a/api/odl.py b/api/odl.py index 2a79c4ec0..1dbf461ae 100644 --- a/api/odl.py +++ b/api/odl.py @@ -346,6 +346,11 @@ def checkin(self, patron: Patron, pin: str, licensepool: LicensePool) -> None: if loan.count() < 1: raise NotCheckedOut() loan_result = loan.one() + + if loan_result.license_pool.open_access: + # If this is an open-access book, we don't need to do anything. + return + self._checkin(loan_result) def _checkin(self, loan: Loan) -> bool: @@ -410,16 +415,25 @@ def checkout( if loan.count() > 0: raise AlreadyCheckedOut() - hold = get_one(_db, Hold, patron=patron, license_pool_id=licensepool.id) - loan_obj = self._checkout(patron, licensepool, hold) + if licensepool.open_access: + loan_start = None + loan_end = None + external_identifier = None + else: + hold = get_one(_db, Hold, patron=patron, license_pool_id=licensepool.id) + loan_obj = self._checkout(patron, licensepool, hold) + loan_start = loan_obj.start + loan_end = loan_obj.end + external_identifier = loan_obj.external_identifier + return LoanInfo( licensepool.collection, licensepool.data_source.name, licensepool.identifier.type, licensepool.identifier.identifier, - loan_obj.start, - loan_obj.end, - external_identifier=loan_obj.external_identifier, + loan_start, + loan_end, + external_identifier=external_identifier, ) def _checkout( @@ -548,25 +562,42 @@ def _fulfill( delivery_mechanism: LicensePoolDeliveryMechanism, ) -> FulfillmentInfo: licensepool = loan.license_pool - doc = self.get_license_status_document(loan) - status = doc.get("status") - if status not in [self.READY_STATUS, self.ACTIVE_STATUS]: - # This loan isn't available for some reason. It's possible - # the distributor revoked it or the patron already returned it - # through the DRM system, and we didn't get a notification - # from the distributor yet. - self.update_loan(loan, doc) - raise CannotFulfill() - - expires = doc.get("potential_rights", {}).get("end") - expires = dateutil.parser.parse(expires) + if licensepool.open_access: + expires = None + requested_mechanism = delivery_mechanism.delivery_mechanism + fulfillment = next( + ( + lpdm + for lpdm in licensepool.delivery_mechanisms + if lpdm.delivery_mechanism == requested_mechanism + ), + None, + ) + if fulfillment is None: + raise FormatNotAvailable() + content_link = fulfillment.resource.representation.public_url + content_type = fulfillment.resource.representation.media_type + else: + doc = self.get_license_status_document(loan) + status = doc.get("status") + + if status not in [self.READY_STATUS, self.ACTIVE_STATUS]: + # This loan isn't available for some reason. It's possible + # the distributor revoked it or the patron already returned it + # through the DRM system, and we didn't get a notification + # from the distributor yet. + self.update_loan(loan, doc) + raise CannotFulfill() + + expires = doc.get("potential_rights", {}).get("end") + expires = dateutil.parser.parse(expires) - links = doc.get("links", []) + links = doc.get("links", []) - content_link, content_type = self._find_content_link_and_type( - links, delivery_mechanism.delivery_mechanism.drm_scheme - ) + content_link, content_type = self._find_content_link_and_type( + links, delivery_mechanism.delivery_mechanism.drm_scheme + ) return FulfillmentInfo( licensepool.collection, @@ -822,7 +853,12 @@ def patron_activity(self, patron: Patron, pin: str) -> list[LoanInfo | HoldInfo] .join(Loan.license_pool) .filter(LicensePool.collection_id == self.collection_id) .filter(Loan.patron == patron) - .filter(Loan.end >= utc_now()) + .filter( + or_( + Loan.end >= utc_now(), + Loan.end == None, + ) + ) ) # Get the patron's holds. If there are any expired holds, delete them. diff --git a/api/odl2.py b/api/odl2.py index 74777b250..f02318591 100644 --- a/api/odl2.py +++ b/api/odl2.py @@ -284,13 +284,16 @@ def _extract_publication_metadata( ) ) - metadata.circulation.licenses = licenses - metadata.circulation.licenses_owned = None - metadata.circulation.licenses_available = None - metadata.circulation.licenses_reserved = None - metadata.circulation.patrons_in_hold_queue = None - metadata.circulation.formats.extend(formats) - metadata.medium = medium + # If we don't have any licenses, then this title is an open-access title. + # So we don't change the circulation data. + if len(licenses) != 0: + metadata.circulation.licenses = licenses + metadata.circulation.licenses_owned = None + metadata.circulation.licenses_available = None + metadata.circulation.licenses_reserved = None + metadata.circulation.patrons_in_hold_queue = None + metadata.circulation.formats.extend(formats) + metadata.medium = medium return metadata diff --git a/api/selftest.py b/api/selftest.py index 8a5d4b048..e1e562449 100644 --- a/api/selftest.py +++ b/api/selftest.py @@ -9,13 +9,11 @@ from core.exceptions import BaseError from core.model import Collection, Library, LicensePool, Patron from core.model.integration import IntegrationConfiguration -from core.selftest import BaseHasSelfTests -from core.selftest import HasSelfTests as CoreHasSelfTests -from core.selftest import HasSelfTestsIntegrationConfiguration, SelfTestResult +from core.selftest import HasSelfTests, SelfTestResult from core.util.problem_detail import ProblemDetail -class HasPatronSelfTests(BaseHasSelfTests, ABC): +class HasPatronSelfTests(HasSelfTests, ABC): """Circulation-specific enhancements for HasSelfTests. Circulation self-tests frequently need to test the ability to act @@ -116,13 +114,7 @@ def _determine_self_test_patron( raise cls._NoValidLibrarySelfTestPatron(message, detail=detail) -class HasSelfTests(CoreHasSelfTests, HasPatronSelfTests): - """Circulation specific self-tests, with the external integration paradigm""" - - -class HasCollectionSelfTests( - HasSelfTestsIntegrationConfiguration, HasPatronSelfTests, ABC -): +class HasCollectionSelfTests(HasPatronSelfTests, ABC): """Extra tests to verify the integrity of imported collections of books. diff --git a/bin/search_index_refresh b/bin/search_index_refresh index f0dfb2a86..eb78dc090 100755 --- a/bin/search_index_refresh +++ b/bin/search_index_refresh @@ -8,7 +8,7 @@ import sys bin_dir = os.path.split(__file__)[0] package_dir = os.path.join(bin_dir, "..") sys.path.append(os.path.abspath(package_dir)) -from core.external_search import SearchIndexCoverageProvider from core.scripts import RunWorkCoverageProviderScript +from core.search.coverage_provider import SearchIndexCoverageProvider RunWorkCoverageProviderScript(SearchIndexCoverageProvider).run() diff --git a/core/coverage.py b/core/coverage.py index 556d993c3..3160b02c7 100644 --- a/core/coverage.py +++ b/core/coverage.py @@ -22,6 +22,7 @@ get_one, ) from core.model.coverage import EquivalencyCoverageRecord +from core.service.container import container_instance from core.util.datetime_helpers import utc_now from core.util.worker_pools import DatabaseJob @@ -201,6 +202,10 @@ def __init__( self.registered_only = registered_only self.collection_id = None + # Call init_resources() to initialize the logging configuration. + self.services = container_instance() + self.services.init_resources() + @property def log(self): if not hasattr(self, "_log"): diff --git a/core/external_search.py b/core/external_search.py index 8479761c3..f03029f77 100644 --- a/core/external_search.py +++ b/core/external_search.py @@ -1,18 +1,15 @@ from __future__ import annotations -import contextlib import datetime import json -import logging import re import time from collections import defaultdict -from collections.abc import Callable, Iterable -from typing import Any +from collections.abc import Iterable from attr import define from flask_babel import lazy_gettext as _ -from opensearch_dsl import SF, Search +from opensearch_dsl import SF from opensearch_dsl.query import ( Bool, DisMax, @@ -27,7 +24,6 @@ ) from opensearch_dsl.query import Query as BaseQuery from opensearch_dsl.query import Range, Regexp, Term, Terms -from opensearchpy import OpenSearch from spellchecker import SpellChecker from core.classifier import ( @@ -36,131 +32,44 @@ GradeLevelClassifier, KeywordBasedClassifier, ) -from core.config import CannotLoadConfiguration -from core.coverage import CoverageFailure, WorkPresentationProvider from core.facets import FacetConstants from core.lane import Pagination from core.metadata_layer import IdentifierData from core.model import ( - Collection, ConfigurationSetting, Contributor, DataSource, Edition, - ExternalIntegration, Identifier, Library, Work, - WorkCoverageRecord, numericrange_to_tuple, ) from core.problem_details import INVALID_INPUT -from core.search.coverage_remover import RemovesSearchCoverage from core.search.migrator import ( SearchDocumentReceiver, - SearchDocumentReceiverType, SearchMigrationInProgress, SearchMigrator, ) -from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory -from core.search.service import SearchService, SearchServiceOpensearch1 -from core.selftest import HasSelfTests +from core.search.service import SearchService from core.util import Values from core.util.cache import CachedData from core.util.datetime_helpers import from_timestamp from core.util.languages import LanguageNames +from core.util.log import LoggerMixin from core.util.personal_names import display_name_to_sort_name from core.util.problem_detail import ProblemDetail from core.util.stopwords import ENGLISH_STOPWORDS -@contextlib.contextmanager -def mock_search_index(mock=None): - """Temporarily mock the ExternalSearchIndex implementation - returned by the load() class method. - """ - try: - ExternalSearchIndex.MOCK_IMPLEMENTATION = mock - yield mock - finally: - ExternalSearchIndex.MOCK_IMPLEMENTATION = None - - -class ExternalSearchIndex(HasSelfTests): - NAME = ExternalIntegration.OPENSEARCH - - # A test may temporarily set this to a mock of this class. - # While that's true, load() will return the mock instead of - # instantiating new ExternalSearchIndex objects. - MOCK_IMPLEMENTATION = None - - WORKS_INDEX_PREFIX_KEY = "works_index_prefix" - DEFAULT_WORKS_INDEX_PREFIX = "circulation-works" - - TEST_SEARCH_TERM_KEY = "a search term" - DEFAULT_TEST_SEARCH_TERM = "test" - CURRENT_ALIAS_SUFFIX = "current" - - SETTINGS = [ - { - "key": ExternalIntegration.URL, - "label": _("URL"), - "required": True, - "format": "url", - }, - { - "key": WORKS_INDEX_PREFIX_KEY, - "label": _("Index prefix"), - "default": DEFAULT_WORKS_INDEX_PREFIX, - "required": True, - "description": _( - "Any Search indexes needed for this application will be created with this unique prefix. In most cases, the default will work fine. You may need to change this if you have multiple application servers using a single Search server." - ), - }, - { - "key": TEST_SEARCH_TERM_KEY, - "label": _("Test search term"), - "default": DEFAULT_TEST_SEARCH_TERM, - "description": _("Self tests will use this value as the search term."), - }, - ] - - SITEWIDE = True - - @classmethod - def search_integration(cls, _db) -> ExternalIntegration | None: - """Look up the ExternalIntegration for Opensearch.""" - return ExternalIntegration.lookup( - _db, ExternalIntegration.OPENSEARCH, goal=ExternalIntegration.SEARCH_GOAL - ) - - @classmethod - def load(cls, _db, *args, **kwargs): - """Load a generic implementation.""" - if cls.MOCK_IMPLEMENTATION: - return cls.MOCK_IMPLEMENTATION - return cls(_db, *args, **kwargs) - - _bulk: Callable[..., Any] - _revision: SearchSchemaRevision - _revision_base_name: str - _revision_directory: SearchRevisionDirectory - _search: Search - _search_migrator: SearchMigrator - _search_service: SearchService - _search_read_pointer: str - _test_search_term: str - +class ExternalSearchIndex(LoggerMixin): def __init__( self, - _db, - url: str | None = None, - test_search_term: str | None = None, - revision_directory: SearchRevisionDirectory | None = None, + service: SearchService, + revision_directory: SearchRevisionDirectory, version: int | None = None, - custom_client_service: SearchService | None = None, - ): + ) -> None: """Constructor :param revision_directory Override the directory of revisions that will be used. If this isn't provided, @@ -168,57 +77,16 @@ def __init__( :param version The specific revision that will be used. If not specified, the highest version in the revision directory will be used. """ - self.log = logging.getLogger("External search index") - - # We can't proceed without a database. - if not _db: - raise CannotLoadConfiguration( - "Cannot load Search configuration without a database.", - ) - - # Load the search integration. - integration = self.search_integration(_db) - if not integration: - raise CannotLoadConfiguration("No search integration configured.") - - if not url: - url = url or integration.url - test_search_term = integration.setting(self.TEST_SEARCH_TERM_KEY).value - - self._test_search_term = test_search_term or self.DEFAULT_TEST_SEARCH_TERM - - if not url: - raise CannotLoadConfiguration("No URL configured to the search server.") - - # Determine the base name we're going to use for storing revisions. - self._revision_base_name = integration.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value - - # Create the necessary search client, and the service used by the schema migrator. - if custom_client_service: - self._search_service = custom_client_service - else: - use_ssl = url.startswith("https://") - self.log.info("Connecting to the search cluster at %s", url) - new_client = OpenSearch(url, use_ssl=use_ssl, timeout=20, maxsize=25) - self._search_service = SearchServiceOpensearch1( - new_client, self._revision_base_name - ) + self._search_service = service # Locate the revision of the search index that we're going to use. # This will fail fast if the requested version isn't available. - self._revision_directory = ( - revision_directory or SearchRevisionDirectory.create() - ) + self._revision_directory = revision_directory if version: self._revision = self._revision_directory.find(version) else: self._revision = self._revision_directory.highest() - # initialize the cached data if not already done so - CachedData.initialize(_db) - # Get references to the read and write pointers. self._search_read_pointer = self._search_service.read_pointer_name() self._search_write_pointer = self._search_service.write_pointer_name() @@ -234,7 +102,8 @@ def start_migration(self) -> SearchMigrationInProgress | None: service=self._search_service, ) return migrator.migrate( - base_name=self._revision_base_name, version=self._revision.version + base_name=self._search_service.base_revision_name, + version=self._revision.version, ) def start_updating_search_documents(self) -> SearchDocumentReceiver: @@ -246,9 +115,6 @@ def start_updating_search_documents(self) -> SearchDocumentReceiver: def clear_search_documents(self) -> None: self._search_service.index_clear_documents(pointer=self._search_write_pointer) - def prime_query_values(self, _db): - JSONQuery.data_sources = _db.query(DataSource).all() - def create_search_doc(self, query_string, filter, pagination, debug): if filter and filter.search_type == "json": query = JSONQuery(query_string, filter) @@ -419,74 +285,6 @@ def remove_work(self, work): pointer=self._search_read_pointer, id=work.id ) - def _run_self_tests(self, _db): - # Helper methods for setting up the self-tests: - - def _search(): - return self.create_search_doc( - self._test_search_term, filter=None, pagination=None, debug=True - ) - - def _works(): - return self.query_works( - self._test_search_term, filter=None, pagination=None, debug=True - ) - - # The self-tests: - - def _search_for_term(): - titles = [(f"{x.sort_title} ({x.sort_author})") for x in _works()] - return titles - - yield self.run_test( - ("Search results for '%s':" % self._test_search_term), _search_for_term - ) - - def _get_raw_doc(): - search = _search() - return json.dumps(search.to_dict(), indent=1) - - yield self.run_test( - ("Search document for '%s':" % (self._test_search_term)), _get_raw_doc - ) - - def _get_raw_results(): - return [json.dumps(x.to_dict(), indent=1) for x in _works()] - - yield self.run_test( - ("Raw search results for '%s':" % (self._test_search_term)), - _get_raw_results, - ) - - def _count_docs(): - service = self.search_service() - client = service.search_client() - return str(client.count()) - - yield self.run_test( - ("Total number of search results for '%s':" % (self._test_search_term)), - _count_docs, - ) - - def _total_count(): - return str(self.count_works(None)) - - yield self.run_test( - "Total number of documents in this search index:", _total_count - ) - - def _collections(): - result = {} - - collections = _db.query(Collection) - for collection in collections: - filter = Filter(collections=[collection]) - result[collection.name] = self.count_works(filter) - - return json.dumps(result, indent=1) - - yield self.run_test("Total number of documents per collection:", _collections) - def initialize_indices(self) -> bool: """Attempt to initialize the indices and pointers for a first time run""" service = self.search_service() @@ -2764,73 +2562,3 @@ def __init__(self, work, hit): def __getattr__(self, k): return getattr(self._work, k) - - -class SearchIndexCoverageProvider(RemovesSearchCoverage, WorkPresentationProvider): - """Make sure all Works have up-to-date representation in the - search index. - """ - - SERVICE_NAME = "Search index coverage provider" - - DEFAULT_BATCH_SIZE = 500 - - OPERATION = WorkCoverageRecord.UPDATE_SEARCH_INDEX_OPERATION - - def __init__(self, *args, **kwargs): - search_index_client = kwargs.pop("search_index_client", None) - super().__init__(*args, **kwargs) - self.search_index_client = search_index_client or ExternalSearchIndex(self._db) - - # - # Try to migrate to the latest schema. If the function returns None, it means - # that no migration is necessary, and we're already at the latest version. If - # we're already at the latest version, then simply upload search documents instead. - # - self.receiver = None - self.migration: None | ( - SearchMigrationInProgress - ) = self.search_index_client.start_migration() - if self.migration is None: - self.receiver: SearchDocumentReceiver = ( - self.search_index_client.start_updating_search_documents() - ) - else: - # We do have a migration, we must clear out the index and repopulate the index - self.remove_search_coverage_records() - - def on_completely_finished(self): - # Tell the search migrator that no more documents are going to show up. - target: SearchDocumentReceiverType = self.migration or self.receiver - target.finish() - - def run_once_and_update_timestamp(self): - # We do not catch exceptions here, so that the on_completely finished should not run - # if there was a runtime error - result = super().run_once_and_update_timestamp() - self.on_completely_finished() - return result - - def process_batch(self, works) -> list[Work | CoverageFailure]: - target: SearchDocumentReceiverType = self.migration or self.receiver - failures = target.add_documents( - documents=self.search_index_client.create_search_documents_from_works(works) - ) - - # Maintain a dictionary of works so that we can efficiently remove failed works later. - work_map: dict[int, Work] = {} - for work in works: - work_map[work.id] = work - - # Remove all the works that failed and create failure records for them. - results: list[Work | CoverageFailure] = [] - for failure in failures: - work = work_map[failure.id] - del work_map[failure.id] - results.append(CoverageFailure(work, repr(failure))) - - # Append all the remaining works that didn't fail. - for work in work_map.values(): - results.append(work) - - return results diff --git a/core/feed/acquisition.py b/core/feed/acquisition.py index 476b9566c..68dba2829 100644 --- a/core/feed/acquisition.py +++ b/core/feed/acquisition.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Generator from typing import TYPE_CHECKING, Any +from dependency_injector.wiring import Provide, inject from sqlalchemy.orm import Query, Session from api.problem_details import NOT_FOUND_ON_REMOTE @@ -426,6 +427,7 @@ def error_message( # Each classmethod creates a different kind of feed @classmethod + @inject def page( cls, _db: Session, @@ -435,7 +437,7 @@ def page( annotator: CirculationManagerAnnotator, facets: FacetsWithEntryPoint | None, pagination: Pagination | None, - search_engine: ExternalSearchIndex | None, + search_engine: ExternalSearchIndex = Provide["search.index"], ) -> OPDSAcquisitionFeed: works = worklist.works( _db, facets=facets, pagination=pagination, search_engine=search_engine @@ -653,6 +655,7 @@ def single_entry( return None @classmethod + @inject def groups( cls, _db: Session, @@ -662,7 +665,7 @@ def groups( annotator: LibraryAnnotator, pagination: Pagination | None = None, facets: FacetsWithEntryPoint | None = None, - search_engine: ExternalSearchIndex | None = None, + search_engine: ExternalSearchIndex = Provide["search.index"], search_debug: bool = False, ) -> OPDSAcquisitionFeed: """Internal method called by groups() when a grouped feed diff --git a/core/feed/annotator/circulation.py b/core/feed/annotator/circulation.py index 1e870d322..896a68c82 100644 --- a/core/feed/annotator/circulation.py +++ b/core/feed/annotator/circulation.py @@ -18,7 +18,7 @@ from api.circulation import BaseCirculationAPI, CirculationAPI from api.config import Configuration from api.lanes import DynamicLane -from api.novelist import NoveListAPI +from api.metadata.novelist import NoveListAPI from core.analytics import Analytics from core.classifier import Classifier from core.config import CannotLoadConfiguration diff --git a/core/feed/opds.py b/core/feed/opds.py index 5daf0c931..97accc03a 100644 --- a/core/feed/opds.py +++ b/core/feed/opds.py @@ -84,7 +84,9 @@ def entry_as_response( logging.getLogger().error(f"Entry data has not been generated for {entry}") raise ValueError(f"Entry data has not been generated") response = OPDSEntryResponse( - response=serializer.serialize_work_entry(entry.computed), + response=serializer.to_string( + serializer.serialize_work_entry(entry.computed) + ), **response_kwargs, ) if isinstance(serializer, OPDS2Serializer): diff --git a/core/integration/goals.py b/core/integration/goals.py index 99db3d2d6..e69f675ed 100644 --- a/core/integration/goals.py +++ b/core/integration/goals.py @@ -10,3 +10,4 @@ class Goals(Enum): LICENSE_GOAL = "licenses" DISCOVERY_GOAL = "discovery" CATALOG_GOAL = "catalog" + METADATA_GOAL = "metadata" diff --git a/core/lane.py b/core/lane.py index 0188a2f7b..0e7a42cd3 100644 --- a/core/lane.py +++ b/core/lane.py @@ -4,9 +4,10 @@ import logging import time from collections import defaultdict -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import quote_plus +from dependency_injector.wiring import Provide, inject from flask_babel import lazy_gettext as _ from opensearchpy.exceptions import OpenSearchException from sqlalchemy import ( @@ -69,6 +70,9 @@ from core.util.opds_writer import OPDSFeed from core.util.problem_detail import ProblemDetail +if TYPE_CHECKING: + from core.external_search import ExternalSearchIndex, WorkSearchResult + class BaseFacets(FacetConstants): """Basic faceting class that doesn't modify a search filter at all. @@ -1780,13 +1784,14 @@ def overview_facets(self, _db, facets): """ return facets + @inject def groups( self, _db, include_sublanes=True, pagination=None, facets=None, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, ): """Extract a list of samples from each child of this WorkList. This @@ -1849,12 +1854,13 @@ def groups( ): yield work, worklist + @inject def works( self, _db, facets=None, pagination=None, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, **kwargs, ): @@ -1877,9 +1883,6 @@ def works( that generates such a list when executed. """ - from core.external_search import ExternalSearchIndex - - search_engine = search_engine or ExternalSearchIndex.load(_db) filter = self.filter(_db, facets) hits = search_engine.query_works( query_string=None, filter=filter, pagination=pagination, debug=debug @@ -2032,6 +2035,7 @@ def search( return results + @inject def _groups_for_lanes( self, _db, @@ -2039,7 +2043,7 @@ def _groups_for_lanes( queryable_lanes, pagination, facets, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, ): """Ask the search engine for groups of featurable works in the @@ -2076,10 +2080,6 @@ def _groups_for_lanes( else: target_size = pagination.size - from core.external_search import ExternalSearchIndex - - search_engine = search_engine or ExternalSearchIndex.load(_db) - if isinstance(self, Lane): parent_lane = self else: @@ -2110,15 +2110,15 @@ def _done_with_lane(lane): by_lane[lane].extend(list(might_need_to_reuse.values())[:num_missing]) used_works = set() - by_lane = defaultdict(list) + by_lane: dict[Lane, list[WorkSearchResult]] = defaultdict(list) working_lane = None - might_need_to_reuse = dict() + might_need_to_reuse: dict[int, WorkSearchResult] = dict() for work, lane in works_and_lanes: if lane != working_lane: # Either we're done with the old lane, or we're just # starting and there was no old lane. if working_lane: - _done_with_lane(working_lane) + _done_with_lane(working_lane) # type: ignore[unreachable] working_lane = lane used_works_this_lane = set() might_need_to_reuse = dict() @@ -2953,12 +2953,12 @@ def uses_customlists(self): return True return False - def update_size(self, _db, search_engine=None): + @inject + def update_size( + self, _db, search_engine: ExternalSearchIndex = Provide["search.index"] + ): """Update the stored estimate of the number of Works in this Lane.""" library = self.get_library(_db) - from core.external_search import ExternalSearchIndex - - search_engine = search_engine or ExternalSearchIndex.load(_db) # Do the estimate for every known entry point. by_entrypoint = dict() @@ -3166,13 +3166,14 @@ def _size_for_facets(self, facets): size = self.size_by_entrypoint[entrypoint_name] return size + @inject def groups( self, _db, include_sublanes=True, pagination=None, facets=None, - search_engine=None, + search_engine: ExternalSearchIndex = Provide["search.index"], debug=False, ): """Return a list of (Work, Lane) 2-tuples @@ -3205,7 +3206,6 @@ def groups( queryable_lanes, pagination=pagination, facets=facets, - search_engine=search_engine, debug=debug, ) diff --git a/core/metadata_layer.py b/core/metadata_layer.py index 9368a522d..442d754b1 100644 --- a/core/metadata_layer.py +++ b/core/metadata_layer.py @@ -42,7 +42,6 @@ get_one_or_create, ) from core.model.licensing import LicenseFunctions, LicenseStatus -from core.service.container import Services from core.util import LanguageCodes from core.util.datetime_helpers import to_utc, utc_now from core.util.median import median @@ -83,7 +82,7 @@ def __init__( @classmethod @inject def from_license_source( - cls, _db, analytics: Analytics = Provide[Services.analytics.analytics], **args + cls, _db, analytics: Analytics = Provide["analytics.analytics"], **args ): """When gathering data from the license source, overwrite all old data from this source with new data from the same source. Also diff --git a/core/model/collection.py b/core/model/collection.py index b9876ba4e..82a8b07ea 100644 --- a/core/model/collection.py +++ b/core/model/collection.py @@ -3,6 +3,7 @@ from collections.abc import Generator from typing import TYPE_CHECKING, Any, TypeVar +from dependency_injector.wiring import Provide, inject from sqlalchemy import ( Boolean, Column, @@ -570,7 +571,10 @@ def restrict_to_ready_deliverable_works( ) return query - def delete(self, search_index: ExternalSearchIndex | None = None) -> None: + @inject + def delete( + self, search_index: ExternalSearchIndex = Provide["search.index"] + ) -> None: """Delete a collection. Collections can have hundreds of thousands of @@ -599,7 +603,7 @@ def delete(self, search_index: ExternalSearchIndex | None = None) -> None: # https://docs.sqlalchemy.org/en/14/orm/cascades.html#notes-on-delete-deleting-objects-referenced-from-collections-and-scalar-relationships work.license_pools.remove(pool) if not work.license_pools: - work.delete(search_index) + work.delete(search_index=search_index) _db.delete(pool) diff --git a/core/model/configuration.py b/core/model/configuration.py index 079b5222b..d96ca0e8c 100644 --- a/core/model/configuration.py +++ b/core/model/configuration.py @@ -41,11 +41,6 @@ class ExternalIntegration(Base): # to this are defined in the circulation manager. PATRON_AUTH_GOAL = "patron_auth" - # These integrations are associated with external services such as - # the metadata wrangler, which provide information about books, - # but not the books themselves. - METADATA_GOAL = "metadata" - # These integrations are associated with external services such as # Opensearch that provide indexed search. SEARCH_GOAL = "search" @@ -102,14 +97,6 @@ class ExternalIntegration(Base): FEEDBOOKS: DataSourceConstants.FEEDBOOKS, } - # Integrations with METADATA_GOAL - BIBBLIO = "Bibblio" - CONTENT_CAFE = "Content Cafe" - NOVELIST = "NoveList Select" - NYPL_SHADOWCAT = "Shadowcat" - NYT = "New York Times" - CONTENT_SERVER = "Content Server" - # Integrations with SEARCH_GOAL OPENSEARCH = "Opensearch" @@ -287,7 +274,7 @@ def setting(self, key): """ return ConfigurationSetting.for_externalintegration(key, self) - @hybrid_property + @property def url(self): return self.setting(self.URL).value @@ -295,7 +282,7 @@ def url(self): def url(self, new_url): self.set_setting(self.URL, new_url) - @hybrid_property + @property def username(self): return self.setting(self.USERNAME).value @@ -303,7 +290,7 @@ def username(self): def username(self, new_username): self.set_setting(self.USERNAME, new_username) - @hybrid_property + @property def password(self): return self.setting(self.PASSWORD).value diff --git a/core/model/datasource.py b/core/model/datasource.py index 085c18c46..955404809 100644 --- a/core/model/datasource.py +++ b/core/model/datasource.py @@ -56,7 +56,7 @@ class DataSource(Base, HasSessionCache, DataSourceConstants): # One DataSource can generate many IDEquivalencies. id_equivalencies: Mapped[list[Equivalency]] = relationship( - "Equivalency", backref="data_source" + "Equivalency", back_populates="data_source" ) # One DataSource can grant access to many LicensePools. diff --git a/core/model/identifier.py b/core/model/identifier.py index 7a687255c..8804574cd 100644 --- a/core/model/identifier.py +++ b/core/model/identifier.py @@ -1118,6 +1118,9 @@ class Equivalency(Base): # Who says? data_source_id = Column(Integer, ForeignKey("datasources.id"), index=True) + data_source: Mapped[DataSource] = relationship( + "DataSource", back_populates="id_equivalencies" + ) # How many distinct votes went into this assertion? This will let # us scale the change to the strength when additional votes come diff --git a/core/model/patron.py b/core/model/patron.py index 4a0f3220f..1e0c7272a 100644 --- a/core/model/patron.py +++ b/core/model/patron.py @@ -277,7 +277,7 @@ def is_last_loan_activity_stale(self) -> bool: seconds=self.loan_activity_max_age ) - @hybrid_property + @property def last_loan_activity_sync(self): """When was the last time we asked the vendors about this patron's loan activity? diff --git a/core/model/work.py b/core/model/work.py index d53c0e2cf..36f59c310 100644 --- a/core/model/work.py +++ b/core/model/work.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast import pytz +from dependency_injector.wiring import Provide, inject from sqlalchemy import ( Boolean, Column, @@ -30,7 +31,6 @@ from sqlalchemy.sql.functions import func from core.classifier import Classifier, WorkClassifier -from core.config import CannotLoadConfiguration from core.model import ( Base, PresentationCalculationPolicy, @@ -58,6 +58,7 @@ # Import related models when doing type checking if TYPE_CHECKING: + from core.external_search import ExternalSearchIndex from core.model import CustomListEntry, Library, LicensePool @@ -2169,19 +2170,13 @@ def top_genre(self): ) return genre.name if genre else None - def delete(self, search_index=None): + @inject + def delete( + self, search_index: ExternalSearchIndex = Provide["search.index"] + ) -> None: """Delete the work from both the DB and search index.""" _db = Session.object_session(self) - if search_index is None: - try: - from core.external_search import ExternalSearchIndex - - search_index = ExternalSearchIndex(_db) - except CannotLoadConfiguration as e: - # No search index is configured. This is fine -- just skip that part. - pass - if search_index is not None: - search_index.remove_work(self) + search_index.remove_work(self) _db.delete(self) diff --git a/core/monitor.py b/core/monitor.py index 4f664757c..b7e3b011d 100644 --- a/core/monitor.py +++ b/core/monitor.py @@ -920,11 +920,9 @@ class WorkReaper(ReaperMonitor): MODEL_CLASS = Work def __init__(self, *args, **kwargs): - from core.external_search import ExternalSearchIndex - search_index_client = kwargs.pop("search_index_client", None) super().__init__(*args, **kwargs) - self.search_index_client = search_index_client or ExternalSearchIndex(self._db) + self.search_index_client = search_index_client or self.services.search.index() def query(self): return ( @@ -935,7 +933,7 @@ def query(self): def delete(self, work): """Delete work from opensearch and database.""" - work.delete(self.search_index_client) + work.delete(search_index=self.search_index_client) ReaperMonitor.REGISTRY.append(WorkReaper) diff --git a/core/query/customlist.py b/core/query/customlist.py index 8a50bafd4..16ebf8bde 100644 --- a/core/query/customlist.py +++ b/core/query/customlist.py @@ -4,6 +4,8 @@ import json from typing import TYPE_CHECKING +from dependency_injector.wiring import Provide, inject + from api.admin.problem_details import ( CUSTOMLIST_ENTRY_NOT_VALID_FOR_LIBRARY, CUSTOMLIST_SOURCE_COLLECTION_MISSING, @@ -13,6 +15,7 @@ from core.model.customlist import CustomList, CustomListEntry from core.model.library import Library from core.model.licensing import LicensePool +from core.service.container import Services from core.util.log import LoggerMixin from core.util.problem_detail import ProblemDetail @@ -33,14 +36,27 @@ def share_locally_with_library( for collection in customlist.collections: if collection not in library.collections: log.info( - f"Unable to share: Collection '{collection.name}' is missing from the library." + f"Unable to share customlist: Collection '{collection.name}' is missing from the library." ) return CUSTOMLIST_SOURCE_COLLECTION_MISSING # All entries must be valid for the library library_collection_ids = [c.id for c in library.collections] entry: CustomListEntry + missing_work_id_count = 0 for entry in customlist.entries: + # It appears that many many lists have entries without works. + # see https://ebce-lyrasis.atlassian.net/browse/PP-708 for the full story. + # Because of this frequently occurring condition, lists are quietly not shared + # with the majority of libraries causing confusion for our users. As it stands + # there is nothing that prevents lists with work-less entries that have already been + # shared from being unshared. So for the time being the least intrusive intervention + # for enabling sharing to work again for many existing lists would be to relax the + # validation when an entry does not have an associated work. + if not entry.work: + missing_work_id_count += 1 + continue + valid_license = ( _db.query(LicensePool) .filter( @@ -50,16 +66,25 @@ def share_locally_with_library( .first() ) if valid_license is None: - log.info(f"Unable to share: No license for work '{entry.work.title}'.") + log.info( + f"Unable to share customlist: No license for work '{entry.work.title}'." + ) + return CUSTOMLIST_ENTRY_NOT_VALID_FOR_LIBRARY + if missing_work_id_count > 0: + log.warning( + f"This list contains {missing_work_id_count} {'entries' if missing_work_id_count > 1 else 'entry'} " + f"without an associated work. " + ) customlist.shared_locally_with_libraries.append(library) log.info( - f"Successfully shared '{customlist.name}' with library '{library.name}'." + f"Successfully shared customlist '{customlist.name}' with library '{library.name}'." ) return True @classmethod + @inject def populate_query_pages( cls, _db: Session, @@ -68,9 +93,10 @@ def populate_query_pages( max_pages: int = 100000, page_size: int = 100, json_query: dict | None = None, + search: ExternalSearchIndex = Provide[Services.search.index], ) -> int: """Populate the custom list while paging through the search query results - :param _db: The database conenction + :param _db: The database connection :param custom_list: The list to be populated :param start_page: Offset of the search will be used from here (based on page_size) :param max_pages: Maximum number of pages to search through @@ -78,11 +104,8 @@ def populate_query_pages( :param json_query: If provided, use this json query rather than that of the custom list """ - log = cls.logger() - search = ExternalSearchIndex(_db) - if not custom_list.auto_update_query: - log.info( + cls.logger().info( f"Cannot populate entries: Custom list {custom_list.name} is missing an auto update query" ) return 0 @@ -113,7 +136,7 @@ def populate_query_pages( ## No more works if not len(works): - log.info( + cls.logger().info( f"{custom_list.name} customlist updated with {total_works_updated} works, moving on..." ) break @@ -131,7 +154,7 @@ def populate_query_pages( for work in works: custom_list.add_entry(work, update_external_index=True) - log.info( + cls.logger().info( f"Updated customlist {custom_list.name} with {total_works_updated} works" ) diff --git a/core/scripts.py b/core/scripts.py index 19e839533..9dcb648c1 100644 --- a/core/scripts.py +++ b/core/scripts.py @@ -16,13 +16,9 @@ from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound -from core.config import CannotLoadConfiguration, Configuration, ConfigurationConstants +from core.config import Configuration, ConfigurationConstants from core.coverage import CollectionCoverageProviderJob, CoverageProviderProgress -from core.external_search import ( - ExternalSearchIndex, - Filter, - SearchIndexCoverageProvider, -) +from core.external_search import ExternalSearchIndex, Filter from core.integration.goals import Goals from core.lane import Lane from core.metadata_layer import TimestampData @@ -58,6 +54,7 @@ from core.monitor import CollectionMonitor, ReaperMonitor from core.opds_import import OPDSImporter, OPDSImportMonitor from core.query.customlist import CustomListQueries +from core.search.coverage_provider import SearchIndexCoverageProvider from core.search.coverage_remover import RemovesSearchCoverage from core.service.container import Services, container_instance from core.util import fast_query_count @@ -2472,13 +2469,7 @@ def __init__( _db = _db or self._db super().__init__(_db) self.output = output or sys.stdout - try: - self.search = search or ExternalSearchIndex(_db) - except CannotLoadConfiguration: - self.out( - "Here's your problem: the search integration is missing or misconfigured." - ) - raise + self.search = search or self.services.search.index() def out(self, s, *args): if not s.endswith("\n"): @@ -2580,7 +2571,7 @@ class UpdateLaneSizeScript(LaneSweeperScript): def __init__(self, _db=None, *args, **kwargs): super().__init__(_db, *args, **kwargs) search = kwargs.get("search_index_client", None) - self._search: ExternalSearchIndex = search or ExternalSearchIndex(self._db) + self._search: ExternalSearchIndex = search or self.services.search.index() def should_process_lane(self, lane): """We don't want to process generic WorkLists -- there's nowhere @@ -2616,7 +2607,7 @@ class RebuildSearchIndexScript(RunWorkCoverageProviderScript, RemovesSearchCover def __init__(self, *args, **kwargs): search = kwargs.get("search_index_client", None) - self.search: ExternalSearchIndex = search or ExternalSearchIndex(self._db) + self.search: ExternalSearchIndex = search or self.services.search.index() super().__init__(SearchIndexCoverageProvider, *args, **kwargs) def do_run(self): diff --git a/core/search/coverage_provider.py b/core/search/coverage_provider.py new file mode 100644 index 000000000..5269df074 --- /dev/null +++ b/core/search/coverage_provider.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from core.coverage import CoverageFailure, WorkPresentationProvider +from core.model import Work, WorkCoverageRecord +from core.search.coverage_remover import RemovesSearchCoverage +from core.search.migrator import ( + SearchDocumentReceiver, + SearchDocumentReceiverType, + SearchMigrationInProgress, +) + + +class SearchIndexCoverageProvider(RemovesSearchCoverage, WorkPresentationProvider): + """Make sure all Works have up-to-date representation in the + search index. + """ + + SERVICE_NAME = "Search index coverage provider" + + DEFAULT_BATCH_SIZE = 500 + + OPERATION = WorkCoverageRecord.UPDATE_SEARCH_INDEX_OPERATION + + def __init__(self, *args, **kwargs): + search_index_client = kwargs.pop("search_index_client", None) + super().__init__(*args, **kwargs) + self.search_index_client = search_index_client or self.services.search.index() + + # + # Try to migrate to the latest schema. If the function returns None, it means + # that no migration is necessary, and we're already at the latest version. If + # we're already at the latest version, then simply upload search documents instead. + # + self.receiver = None + self.migration: None | ( + SearchMigrationInProgress + ) = self.search_index_client.start_migration() + if self.migration is None: + self.receiver: SearchDocumentReceiver = ( + self.search_index_client.start_updating_search_documents() + ) + else: + # We do have a migration, we must clear out the index and repopulate the index + self.remove_search_coverage_records() + + def on_completely_finished(self): + # Tell the search migrator that no more documents are going to show up. + target: SearchDocumentReceiverType = self.migration or self.receiver + target.finish() + + def run_once_and_update_timestamp(self): + # We do not catch exceptions here, so that the on_completely finished should not run + # if there was a runtime error + result = super().run_once_and_update_timestamp() + self.on_completely_finished() + return result + + def process_batch(self, works) -> list[Work | CoverageFailure]: + target: SearchDocumentReceiverType = self.migration or self.receiver + failures = target.add_documents( + documents=self.search_index_client.create_search_documents_from_works(works) + ) + + # Maintain a dictionary of works so that we can efficiently remove failed works later. + work_map: dict[int, Work] = {} + for work in works: + work_map[work.id] = work + + # Remove all the works that failed and create failure records for them. + results: list[Work | CoverageFailure] = [] + for failure in failures: + work = work_map[failure.id] + del work_map[failure.id] + results.append(CoverageFailure(work, repr(failure))) + + # Append all the remaining works that didn't fail. + for work in work_map.values(): + results.append(work) + + return results diff --git a/core/search/service.py b/core/search/service.py index bf751fd91..b1a39a9d2 100644 --- a/core/search/service.py +++ b/core/search/service.py @@ -72,6 +72,11 @@ class SearchService(ABC): sensible types, rather than the untyped pile of JSON the actual search client provides. """ + @property + @abstractmethod + def base_revision_name(self) -> str: + """The base name used for all indexes.""" + @abstractmethod def read_pointer_name(self) -> str: """Get the name used for the read pointer.""" @@ -164,7 +169,7 @@ def __init__(self, client: OpenSearch, base_revision_name: str): self._logger = logging.getLogger(SearchServiceOpensearch1.__name__) self._client = client self._search = Search(using=self._client) - self.base_revision_name = base_revision_name + self._base_revision_name = base_revision_name self._multi_search = MultiSearch(using=self._client) self._indexes_created: list[str] = [] @@ -174,6 +179,10 @@ def __init__(self, client: OpenSearch, base_revision_name: str): body={"persistent": {"action.auto_create_index": "false"}} ) + @property + def base_revision_name(self) -> str: + return self._base_revision_name + def indexes_created(self) -> list[str]: return self._indexes_created diff --git a/core/selftest.py b/core/selftest.py index 5136629b3..cd9d86a8b 100644 --- a/core/selftest.py +++ b/core/selftest.py @@ -2,8 +2,6 @@ """ from __future__ import annotations -import json -import logging import sys import traceback from abc import ABC, abstractmethod @@ -13,7 +11,7 @@ from sqlalchemy.orm import Session -from core.model import Collection, ExternalIntegration +from core.model import Collection from core.model.integration import IntegrationConfiguration from core.util.datetime_helpers import utc_now from core.util.http import IntegrationException @@ -136,7 +134,7 @@ def debug_message(self) -> str | None: P = ParamSpec("P") -class BaseHasSelfTests(ABC): +class HasSelfTests(LoggerMixin, ABC): """An object capable of verifying its own setup by running a series of self-tests. """ @@ -267,97 +265,6 @@ def test_failure( result.exception = exception return result - @abstractmethod - def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: - """Run the self-tests. - - :return: A generator that yields SelfTestResult objects. - """ - ... - - @abstractmethod - def store_self_test_results( - self, _db: Session, value: dict[str, Any], results: list[SelfTestResult] - ) -> None: - ... - - -class HasSelfTests(BaseHasSelfTests, ABC): - """An object capable of verifying its own setup by running a - series of self-tests. - """ - - # Self-test results are stored in a ConfigurationSetting with this name, - # associated with the appropriate ExternalIntegration. - SELF_TEST_RESULTS_SETTING = "self_test_results" - - def store_self_test_results( - self, _db: Session, value: dict[str, Any], results: list[SelfTestResult] - ) -> None: - """Store the results of a self-test in the database.""" - integration: ExternalIntegration | None - from core.external_search import ExternalSearchIndex - - if isinstance(self, ExternalSearchIndex): - integration = self.search_integration(_db) - for idx, result in enumerate(value.get("results")): # type: ignore[arg-type] - if isinstance(results[idx].result, list): - result["result"] = results[idx].result - else: - integration = self.external_integration(_db) - - if integration is not None: - integration.setting(self.SELF_TEST_RESULTS_SETTING).value = json.dumps( - value - ) - - @classmethod - def prior_test_results( - cls: type[Self], - _db: Session, - constructor_method: Callable[..., Self] | None = None, - *args: Any, - **kwargs: Any, - ) -> dict[str, Any] | None | str: - """Retrieve the last set of test results from the database. - - The arguments here are the same as the arguments to run_self_tests. - """ - constructor_method = constructor_method or cls - instance = constructor_method(*args, **kwargs) - integration: ExternalIntegration | None - - from core.external_search import ExternalSearchIndex - - if isinstance(instance, ExternalSearchIndex): - integration = instance.search_integration(_db) - else: - integration = instance.external_integration(_db) - - if integration: - return ( - integration.setting(cls.SELF_TEST_RESULTS_SETTING).json_value - or "No results yet" - ) - - return None - - def external_integration(self, _db: Session) -> ExternalIntegration | None: - """Locate the ExternalIntegration associated with this object. - The status of the self-tests will be stored as a ConfigurationSetting - on this ExternalIntegration. - - By default, there is no way to get from an object to its - ExternalIntegration, and self-test status will not be stored. - """ - logger = logging.getLogger("Self-test system") - logger.error( - "No ExternalIntegration was found. Self-test results will not be stored." - ) - return None - - -class HasSelfTestsIntegrationConfiguration(BaseHasSelfTests, LoggerMixin, ABC): def store_self_test_results( self, _db: Session, value: dict[str, Any], results: list[SelfTestResult] ) -> None: @@ -372,7 +279,7 @@ def store_self_test_results( @classmethod def load_self_test_results( cls, integration: IntegrationConfiguration | None - ) -> dict[str, Any] | None: + ) -> dict[str, Any] | str | None: if integration is None: cls.logger().error( "No IntegrationConfiguration was found. Self-test results could not be loaded." @@ -385,24 +292,19 @@ def load_self_test_results( ) return None + if integration.self_test_results == {}: + # No self-test results have been stored yet. + return "No results yet" + return integration.self_test_results - @classmethod - def prior_test_results( - cls: type[Self], - _db: Session, - constructor_method: Callable[..., Self] | None = None, - *args: Any, - **kwargs: Any, - ) -> dict[str, Any] | None | str: - """Retrieve the last set of test results from the database. + @abstractmethod + def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: + """Run the self-tests. - The arguments here are the same as the arguments to run_self_tests. + :return: A generator that yields SelfTestResult objects. """ - constructor_method = constructor_method or cls - instance = constructor_method(*args, **kwargs) - integration: IntegrationConfiguration | None = instance.integration(_db) - return cls.load_self_test_results(integration) or "No results yet" + ... @abstractmethod def integration(self, _db: Session) -> IntegrationConfiguration | None: diff --git a/core/service/container.py b/core/service/container.py index 273dbdc3b..a02f71a75 100644 --- a/core/service/container.py +++ b/core/service/container.py @@ -6,6 +6,8 @@ from core.service.analytics.container import AnalyticsContainer from core.service.logging.configuration import LoggingConfiguration from core.service.logging.container import Logging +from core.service.search.configuration import SearchConfiguration +from core.service.search.container import Search from core.service.storage.configuration import StorageConfiguration from core.service.storage.container import Storage @@ -29,28 +31,43 @@ class Services(DeclarativeContainer): storage=storage, ) - -def create_container() -> Services: - container = Services() - container.config.from_dict( - { - "storage": StorageConfiguration().dict(), - "logging": LoggingConfiguration().dict(), - "analytics": AnalyticsConfiguration().dict(), - } + search = Container( + Search, + config=config.search, ) + + +def wire_container(container: Services) -> None: container.wire( modules=[ - "core.metadata_layer", - "api.odl", "api.axis", "api.bibliotheca", - "api.enki", "api.circulation_manager", + "api.enki", + "api.odl", "api.overdrive", "core.feed.annotator.circulation", + "core.feed.acquisition", + "core.lane", + "core.metadata_layer", + "core.model.collection", + "core.model.work", + "core.query.customlist", ] ) + + +def create_container() -> Services: + container = Services() + container.config.from_dict( + { + "storage": StorageConfiguration().dict(), + "logging": LoggingConfiguration().dict(), + "analytics": AnalyticsConfiguration().dict(), + "search": SearchConfiguration().dict(), + } + ) + wire_container(container) return container diff --git a/core/service/search/configuration.py b/core/service/search/configuration.py new file mode 100644 index 000000000..b3e2a14e7 --- /dev/null +++ b/core/service/search/configuration.py @@ -0,0 +1,13 @@ +from pydantic import AnyHttpUrl + +from core.service.configuration import ServiceConfiguration + + +class SearchConfiguration(ServiceConfiguration): + url: AnyHttpUrl + index_prefix: str = "circulation-works" + timeout: int = 20 + maxsize: int = 25 + + class Config: + env_prefix = "PALACE_SEARCH_" diff --git a/core/service/search/container.py b/core/service/search/container.py new file mode 100644 index 000000000..6a171052a --- /dev/null +++ b/core/service/search/container.py @@ -0,0 +1,35 @@ +from dependency_injector import providers +from dependency_injector.containers import DeclarativeContainer +from dependency_injector.providers import Provider +from opensearchpy import OpenSearch + +from core.external_search import ExternalSearchIndex +from core.search.revision_directory import SearchRevisionDirectory +from core.search.service import SearchServiceOpensearch1 + + +class Search(DeclarativeContainer): + config = providers.Configuration() + + client: Provider[OpenSearch] = providers.Singleton( + OpenSearch, + hosts=config.url, + timeout=config.timeout, + maxsize=config.maxsize, + ) + + service: Provider[SearchServiceOpensearch1] = providers.Singleton( + SearchServiceOpensearch1, + client=client, + base_revision_name=config.index_prefix, + ) + + revision_directory: Provider[SearchRevisionDirectory] = providers.Singleton( + SearchRevisionDirectory.create, + ) + + index: Provider[ExternalSearchIndex] = providers.Singleton( + ExternalSearchIndex, + service=service, + revision_directory=revision_directory, + ) diff --git a/docker-compose.yml b/docker-compose.yml index 258847373..f8921f3d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,51 @@ version: "3.9" -# Common set of CM environment variables +# Common CM setup # see: https://github.com/compose-spec/compose-spec/blob/master/spec.md#extension -x-cm-env-variables: &cm-env-variables - SIMPLIFIED_PRODUCTION_DATABASE: "postgresql://palace:test@pg:5432/circ" - PALACE_STORAGE_ACCESS_KEY: "palace" - PALACE_STORAGE_SECRET_KEY: "test123456789" - PALACE_STORAGE_ENDPOINT_URL: "http://minio:9000" - PALACE_STORAGE_PUBLIC_ACCESS_BUCKET: "public" - PALACE_STORAGE_ANALYTICS_BUCKET: "analytics" - PALACE_STORAGE_URL_TEMPLATE: "http://localhost:9000/{bucket}/{key}" - PALACE_REPORTING_NAME: "TEST CM" +x-cm-variables: &cm + platform: "${BUILD_PLATFORM-}" + environment: + SIMPLIFIED_PRODUCTION_DATABASE: "postgresql://palace:test@pg:5432/circ" + PALACE_SEARCH_URL: "http://os:9200" + PALACE_STORAGE_ACCESS_KEY: "palace" + PALACE_STORAGE_SECRET_KEY: "test123456789" + PALACE_STORAGE_ENDPOINT_URL: "http://minio:9000" + PALACE_STORAGE_PUBLIC_ACCESS_BUCKET: "public" + PALACE_STORAGE_ANALYTICS_BUCKET: "analytics" + PALACE_STORAGE_URL_TEMPLATE: "http://localhost:9000/{bucket}/{key}" + PALACE_REPORTING_NAME: "TEST CM" + depends_on: + pg: + condition: service_healthy + minio: + condition: service_healthy + os: + condition: service_healthy + +x-cm-build: &cm-build + context: . + dockerfile: docker/Dockerfile + args: + - BASE_IMAGE=${BUILD_BASE_IMAGE-ghcr.io/thepalaceproject/circ-baseimage:latest} + cache_from: + - ${BUILD_CACHE_FROM-ghcr.io/thepalaceproject/circ-webapp:main} services: # example docker compose configuration for testing and development webapp: + <<: *cm build: - context: . - dockerfile: docker/Dockerfile + <<: *cm-build target: webapp ports: - "6500:80" - environment: *cm-env-variables scripts: + <<: *cm build: - context: . - dockerfile: docker/Dockerfile + <<: *cm-build target: scripts - environment: *cm-env-variables pg: image: "postgres:12" @@ -37,6 +53,11 @@ services: POSTGRES_USER: palace POSTGRES_PASSWORD: test POSTGRES_DB: circ + healthcheck: + test: ["CMD-SHELL", "pg_isready -U palace -d circ"] + interval: 30s + timeout: 30s + retries: 3 minio: image: "bitnami/minio:2023.2.27" @@ -48,11 +69,22 @@ services: MINIO_ROOT_PASSWORD: "test123456789" MINIO_SCHEME: "http" MINIO_DEFAULT_BUCKETS: "public:download,analytics" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 os: build: dockerfile: docker/Dockerfile.ci target: opensearch + context: . environment: - discovery.type: single-node - DISABLE_SECURITY_PLUGIN: true + discovery.type: "single-node" + DISABLE_SECURITY_PLUGIN: "true" + healthcheck: + test: curl --silent http://localhost:9200 >/dev/null; if [[ $$? == 52 ]]; then echo 0; else echo 1; fi + interval: 30s + timeout: 10s + retries: 5 diff --git a/docker/ci/check_service_status.sh b/docker/ci/check_service_status.sh index a76ff6d02..9adbe35f8 100644 --- a/docker/ci/check_service_status.sh +++ b/docker/ci/check_service_status.sh @@ -4,7 +4,7 @@ function wait_for_runit() # The container to run the command in container="$1" - timeout 120s grep -q 'Runit started' <(docker logs "$container" -f 2>&1) + timeout 120s grep -q 'Runit started' <(docker compose logs "$container" -f 2>&1) } # A method to check that runit services are running inside the container @@ -17,7 +17,7 @@ function check_service_status() service="$2" # Check the status of the service. - service_status=$(docker exec "$container" /bin/bash -c "sv check $service") + service_status=$(docker compose exec "$container" /bin/bash -c "sv check $service") # Get the exit code for the sv call. sv_status=$? @@ -34,7 +34,7 @@ function check_crontab() { container="$1" # Installing the crontab will reveal any errors and exit with an error code - $(docker exec "$container" /bin/bash -c "crontab /etc/cron.d/circulation") + $(docker compose exec "$container" /bin/bash -c "crontab /etc/cron.d/circulation") validate_status=$? if [[ "$validate_status" != 0 ]]; then echo " FAIL: crontab is incorrect" @@ -48,7 +48,7 @@ function run_script() { container="$1" script="$2" - output=$(docker exec "$container" /bin/bash -c "$script") + output=$(docker compose exec "$container" /bin/bash -c "$script") script_status=$? if [[ "$script_status" != 0 ]]; then echo " FAIL: script run failed" diff --git a/docker/ci/test_migrations.sh b/docker/ci/test_migrations.sh index 2ebe133e1..e6523c04d 100755 --- a/docker/ci/test_migrations.sh +++ b/docker/ci/test_migrations.sh @@ -26,7 +26,7 @@ compose_cmd() { run_in_container() { - compose_cmd run --build --rm webapp /bin/bash -c "source env/bin/activate && $*" + compose_cmd run --build --rm --no-deps webapp /bin/bash -c "source env/bin/activate && $*" } cleanup() { @@ -105,10 +105,12 @@ if [[ -z $first_migration_commit ]]; then exit 1 fi -echo "Starting containers and initializing database at commit ${first_migration_commit}" -git checkout -q "${first_migration_commit}" +echo "Starting containers" compose_cmd down compose_cmd up -d pg + +echo "Initializing database at commit ${first_migration_commit}" +git checkout -q "${first_migration_commit}" run_in_container "./bin/util/initialize_instance" initialize_exit_code=$? if [[ $initialize_exit_code -ne 0 ]]; then diff --git a/docker/ci/test_scripts.sh b/docker/ci/test_scripts.sh index d283e8709..55dc74de1 100755 --- a/docker/ci/test_scripts.sh +++ b/docker/ci/test_scripts.sh @@ -12,6 +12,9 @@ source "${dir}/check_service_status.sh" # Wait for container to start wait_for_runit "$container" +# Make sure database initialization completed successfully +timeout 240s grep -q 'Initialization complete' <(docker compose logs "$container" -f 2>&1) + # Make sure that cron is running in the scripts container check_service_status "$container" /etc/service/cron diff --git a/docker/ci/test_webapp.sh b/docker/ci/test_webapp.sh index aa5680b1c..51241f919 100755 --- a/docker/ci/test_webapp.sh +++ b/docker/ci/test_webapp.sh @@ -17,10 +17,10 @@ check_service_status "$container" /etc/service/nginx check_service_status "$container" /etc/service/uwsgi # Wait for UWSGI to be ready to accept connections. -timeout 240s grep -q 'WSGI app .* ready in [0-9]* seconds' <(docker logs "$container" -f 2>&1) +timeout 240s grep -q 'WSGI app .* ready in [0-9]* seconds' <(docker compose logs "$container" -f 2>&1) # Make sure the web server is running. -healthcheck=$(docker exec "$container" curl --write-out "%{http_code}" --silent --output /dev/null http://localhost/healthcheck.html) +healthcheck=$(docker compose exec "$container" curl --write-out "%{http_code}" --silent --output /dev/null http://localhost/healthcheck.html) if ! [[ ${healthcheck} == "200" ]]; then exit 1 else @@ -28,7 +28,7 @@ else fi # Also make sure the app server is running. -feed_type=$(docker exec "$container" curl --write-out "%{content_type}" --silent --output /dev/null http://localhost/version.json) +feed_type=$(docker compose exec "$container" curl --write-out "%{content_type}" --silent --output /dev/null http://localhost/version.json) if ! [[ ${feed_type} == "application/json" ]]; then exit 1 else diff --git a/poetry.lock b/poetry.lock index 920695ab3..b9b890ade 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1145,13 +1145,13 @@ typing = ["typing-extensions (>=4.8)"] [[package]] name = "firebase-admin" -version = "6.3.0" +version = "6.4.0" description = "Firebase Admin Python SDK" optional = false python-versions = ">=3.7" files = [ - {file = "firebase_admin-6.3.0-py3-none-any.whl", hash = "sha256:fcada47664f38b6da67fd924108b98029370554c9f762895d3f83e912cac5ab9"}, - {file = "firebase_admin-6.3.0.tar.gz", hash = "sha256:f040625b8cd3a15f99f84a797fe288ad5993c4034c355b7df3c37a99d39400e6"}, + {file = "firebase_admin-6.4.0-py3-none-any.whl", hash = "sha256:aa06f19f0aa8b9b929dbe5cd13677c9ba05fe7ff819564f420aae02c645c6322"}, + {file = "firebase_admin-6.4.0.tar.gz", hash = "sha256:4ac83ee00abe68498b9f08d701b550a77b3a59efba610a9e2fb3d7b1515166c6"}, ] [package.dependencies] @@ -1164,13 +1164,13 @@ pyjwt = {version = ">=2.5.0", extras = ["crypto"]} [[package]] name = "flask" -version = "3.0.0" +version = "3.0.1" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.8" files = [ - {file = "flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638"}, - {file = "flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58"}, + {file = "flask-3.0.1-py3-none-any.whl", hash = "sha256:ca631a507f6dfe6c278ae20112cea3ff54ff2216390bf8880f6b035a5354af13"}, + {file = "flask-3.0.1.tar.gz", hash = "sha256:6489f51bb3666def6f314e15f19d50a1869a19ae0e8c9a3641ffe66c77d42403"}, ] [package.dependencies] @@ -1897,119 +1897,101 @@ deprecated = "*" [[package]] name = "levenshtein" -version = "0.23.0" +version = "0.24.0" description = "Python extension for computing string edit distances and similarities." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d3f2b8e67915268c49f0faa29a29a8c26811a4b46bd96dd043bc8557428065d"}, - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10b980dcc865f8fe04723e448fac4e9a32cbd21fb41ab548725a2d30d9a22429"}, - {file = "Levenshtein-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f8c8c48217b2733ae5bd8ef14e0ad730a30d113c84dc2cfc441435ef900732b"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:854a0962d6f5852b891b6b5789467d1e72b69722df1bc0dd85cbf70efeddc83f"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5abc4ee22340625ec401d6f11136afa387d377b7aa5dad475618ffce1f0d2e2f"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20f79946481052bbbee5284c755aa0a5feb10a344d530e014a50cb9544745dd3"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6084fc909a218843bb55723fde64a8a58bac7e9086854c37134269b3f946aeb"}, - {file = "Levenshtein-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0acaae1c20c8ed37915b0cde14b5c77d5a3ba08e05f9ce4f55e16843de9c7bb8"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:54a51036b02222912a029a6efa2ce1ee2be49c88e0bb32995e0999feba183913"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:68ec2ef442621027f290cb5cef80962889d86fff3e405e5d21c7f9634d096bbf"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:d8ba18720bafa4a65f07baba8c3228e98a6f8da7455de4ec58ae06de4ecdaea0"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:af1b70cac87c5627cd2227823318fa39c64fbfed686c8c3c2f713f72bc25813b"}, - {file = "Levenshtein-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe2810c42cc5bca15eeb4a2eb192b1f74ceef6005876b1a166ecbde1defbd22d"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win32.whl", hash = "sha256:89a0829637221ff0fd6ce63dfbe59e22b25eeba914d50e191519b9d9b8ccf3e9"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:b8bc81d59205558326ac75c97e236fd72b8bcdf63fcdbfb7387bd63da242b209"}, - {file = "Levenshtein-0.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:151046d1c70bdf01ede01f46467c11151ceb9c86fefaf400978b990110d0a55e"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7e992de09832ee11b35910c05c1581e8a9ab8ea9737c2f582c7eb540e2cdde69"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5e3461d29b3188518464bd3121fc64635ff884ae544147b5d326ce13c50d36"}, - {file = "Levenshtein-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1772c4491f6ef6504e591c0dd60e1e418b2015074c3d56ee93af6b1a019906ee"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e125c92cd0ac3b53c4c80fcf2890d89a1d19ff4979dc804031773bc90223859f"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d2f608c5ce7b9a0a0af3c910f43ea7eb060296655aa127b10e4af7be5559303"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe5c3b7d96a838d9d86bb4ec57495749965e598a3ea2c5b877a61aa09478bab7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249eaa351b5355b3e3ca7e3a8e2a0bca7bff4491c89a0b0fa3b9d0614cf3efeb"}, - {file = "Levenshtein-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0033a243510e829ead1ae62720389c9f17d422a98c0525da593d239a9ff434e5"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f956ad16cab9267c0e7d382a37b4baca6bf3bf1637a76fa95fdbf9dd3ea774d7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3789e4aeaeb830d944e1f502f9aa9024e9cd36b68d6eba6892df7972b884abd7"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f91335f056b9a548070cb87b3e6cf017a18b27d34a83f222bdf46a5360615f11"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3497eda857e70863a090673a82442877914c57b5f04673c782642e69caf25c0c"}, - {file = "Levenshtein-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5e17ea59115179c269c6daea52415faaf54c6340d4ad91d9012750845a445a13"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win32.whl", hash = "sha256:da2063cee1fbecc09e1692e7c4de7624fd4c47a54ee7588b7ea20540f8f8d779"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d3b9c9e2852eca20de6bd8ca7f47d817a056993fd4927a4d50728b62315376b"}, - {file = "Levenshtein-0.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:ef2e3e93ae612ac87c3a28f08e8544b707d67e99f9624e420762a7c275bb13c5"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85220b27a47df4a5106ef13d43b6181d73da77d3f78646ec7251a0c5eb08ac40"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bb77b3ade7f256ca5882450aaf129be79b11e074505b56c5997af5058a8f834"}, - {file = "Levenshtein-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b487f08c32530ee608e8aab0c4075048262a7f5a6e113bac495b05154ae427"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f91d0a5d3696e373cae08c80ec99a4ff041e562e55648ebe582725cba555190"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fddda71ae372cd835ffd64990f0d0b160409e881bf8722b6c5dc15dc4239d7db"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7664bcf9a12e62c672a926c4579f74689507beaa24378ad7664f0603b0dafd20"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6d07539502610ee8d6437a77840feedefa47044ab0f35cd3bc37adfc63753bd"}, - {file = "Levenshtein-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:830a74b6a045a13e1b1d28af62af9878aeae8e7386f14888c84084d577b92771"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f29cbd0c172a8fc1d51eaacd163bdc11596aded5a90db617e6b778c2258c7006"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:df0704fd6a30a7c27c03655ae6dc77345c1655634fe59654e74bb06a3c7c1357"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:0ab52358f54ee48ad7656a773a0c72ef89bb9ba5acc6b380cfffd619fb223a23"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:f0a86394c9440e23a29f48f2bbc460de7b19950f46ec2bea3be8c2090839bb29"}, - {file = "Levenshtein-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a689e6e0514f48a434e7ee44cc1eb29c34b21c51c57accb304eac97fba87bf48"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win32.whl", hash = "sha256:2d3229c1336498c2b72842dd4c850dff1040588a5468abe5104444a372c1a573"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:5b9b6a8509415bc214d33f5828d7c700c80292ea25f9d9e8cba95ad5a74b3cdf"}, - {file = "Levenshtein-0.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:5a61606bad3afb9fcec0a2a21871319c3f7da933658d2e0e6e55ab4a34814f48"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:078bb87ea32a28825900f5d29ba2946dc9cf73094dfed4ba5d70f042f2435609"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26b468455f29fb255b62c22522026985cb3181a02e570c8b37659fedb1bc0170"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc62b2f74e4050f0a1261a34e11fd9e7c6d80a45679c0e02ac452b16fda7b34"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b65b0b4e8b88e8326cdbfd3ec119953a0b10b514947f4bd03a4ed0fc58f6471"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bccaf7f16b9da5edb608705edc3c38401e83ea0ff04c6375f25c6fc15e88f9b3"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b35f752d04c0828fb1877d9bee5d1786b2574ec3b1cba0533008aa1ff203712"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2c32f86bb54b9744c95c27b5398f108158cc6a87c5dbb3ad5a344634bf9b07d3"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fa8b65f483cdd3114d41736e0e9c3841e7ee6ac5861bae3d26e21e19faa229ff"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:9fdf67c10a5403b1668d1b6ade7744d20790367b10866d27394e64716992c3e4"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:eb6dfba3264b38a3e95cac8e64f318ad4c27e2232f6c566a69b3b113115c06ef"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8541f1b7516290f6ccc3faac9aea681183c5d0b1f8078b957ae41dfbd5b93b58"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-win32.whl", hash = "sha256:f35b138bb698b29467627318af9258ec677e021e0816ae0da9b84f9164ed7518"}, - {file = "Levenshtein-0.23.0-cp37-cp37m-win_amd64.whl", hash = "sha256:936320113eadd3d71d9ce371d9027b1c56299001b48ed197a0db4140e1d13bbd"}, - {file = "Levenshtein-0.23.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:da64e19e1ec0c1e8a1cd77c4802a0d656f8a6e0ab7a1479d435a9d2575e473f8"}, - {file = "Levenshtein-0.23.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e729781b6134a6e3b380a2d8eae0843a230fc3716bdc8bba4cde2b0ce260982b"}, - {file = "Levenshtein-0.23.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:97d0841a2682a3c302f70537e8316077e56795062c6f629714f5d0771f7a5838"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:727a679d19b18a0b4532abf87f9788070bcd94b78ff07135abe41c716bccbb7d"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48c8388a321e55c1feeef543b49fc969be6a5cf6bcf4dcb5dced82f5fea6793c"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58f8b8f5d4348e470e8c0d4e9f7c23a8f7cfc3cbd8024cc5a1fc68cc81f7d6cb"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:549170257f052289df93a13526877cb397d351b0c8a3e4c9ae3936aeafd8ad17"}, - {file = "Levenshtein-0.23.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d32f3b28065e430d54781e1f3b31198b6bfc21e6d565f0c06218e7618884551"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ecc8c12e710212c4d959fda3a52377ae6a30fa204822f2e63fd430e018be3d6f"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:88b47fbabbd9cee8be5d6c26ac4d599dd66146628b9ca23d9f4f209c4e3e143e"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:5106bce4e94bc1ae137b50d1e5f49b726997be879baf66eafc6ee365adec3db5"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d36634491e06234672492715bc6ff7be61aeaf44822cb366dbbe9d924f2614cc"}, - {file = "Levenshtein-0.23.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a591c94f7047d105c29630e7606a2b007f96cf98651fb93e9f820272b0361e02"}, - {file = "Levenshtein-0.23.0-cp38-cp38-win32.whl", hash = "sha256:9fce199af18d459c8f19747501d1e852d86550162e7ccdc2c193b44e55d9bbfb"}, - {file = "Levenshtein-0.23.0-cp38-cp38-win_amd64.whl", hash = "sha256:b4303024ffea56fd164a68f80f23df9e9158620593b7515c73c885285ec6a558"}, - {file = "Levenshtein-0.23.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:73aed4856e672ab12769472cf7aece04b4a6813eb917390d22e58002576136e0"}, - {file = "Levenshtein-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4e93dbfdf08360b4261a2385340d26ac491a1bf9bd17bf22a59636705d2d6479"}, - {file = "Levenshtein-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b847f716fc314cf83d128fedc2c16ffdff5431a439db412465c4b0ac1762478e"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0d567beb47cd403394bf241df8cfc14499279d0f3a6675f89b667249841aab1"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e13857d870048ff58ce95c8eb32e10285918ee74e1c9bf1825af08dd49b0bc6"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4250f507bb1b7501f7187af8345e200cbc1a58ceb3730bf4e3fdc371fe732c0"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fb90de8a279ce83797bcafbbfe6d641362c3c96148c17d8c8612dddb02744c5"}, - {file = "Levenshtein-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:039dc7323fd28de44d6c13a334a34ab1ddee598762cb2dae3223ca1f083577f9"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d5739f513cb02039f970054eabeccc62696ed2a1afff6e17f75d5492a3ed8d74"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2a3801a0463791440b4350b734e4ec0dbc140b675a3ce9ef936feed06b23c58d"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:606ba30bbdf06fc51b0a763760e113dea9085011a2399cf4b1f72316836e4d03"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:14c5f90859e512004cc25b50b79c7ae6f068ebe69a7213a9018c83bd88c1305b"}, - {file = "Levenshtein-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c8a75233798e334fd53305656ffcf0601f60e9ff461af759677006c07c060939"}, - {file = "Levenshtein-0.23.0-cp39-cp39-win32.whl", hash = "sha256:9a271d50643cf927bfc002d397b4f715abdbc6ca46a5a93d1d66a033eabaa5f3"}, - {file = "Levenshtein-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:684118d9e070e00df91bc4bd276e0559df7bb2319659699dafda16b5a0229553"}, - {file = "Levenshtein-0.23.0-cp39-cp39-win_arm64.whl", hash = "sha256:98412a7bdc49c7fbb493be3c3e7fd2f874eff29ed636b8c0eca325a1e3e74264"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:760c964ff0be8dea5f7eda20314cf66238fdd0fec63f1ce9c474736bb2904924"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de42400ea86e3e8be3dc7f9b3b9ed51da7fd06dc2f3a426d7effd7fbf35de848"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2080ee52aeac03854a0c6e73d4214d5be2120bdd5f16def4394f9fbc5666e04"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb00ecae116e62801613788d8dc3938df26f582efce5a3d3320e9692575e7c4d"}, - {file = "Levenshtein-0.23.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f351694f65d4df48ee2578d977d37a0560bd3e8535e85dfe59df6abeed12bd6e"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34859c5ff7261f25daea810b5439ad80624cbb9021381df2c390c20eb75b79c6"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ece1d077d9006cff329bb95eb9704f407933ff4484e5d008a384d268b993439"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35ce82403730dd2a3b397abb2535786af06835fcf3dc40dc8ea67ed589bbd010"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a88aa3b5f49aeca08080b6c3fa7e1095d939eafb13f42dbe8f1b27ff405fd43"}, - {file = "Levenshtein-0.23.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:748fbba6d9c04fc39b956b44ccde8eb14f34e21ab68a0f9965aae3fa5c8fdb5e"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:60440d583986e344119a15cea9e12099f3a07bdddc1c98ec2dda69e96429fb25"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b048a83b07fc869648460f2af1255e265326d75965157a165dde2d9ba64fa73"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4be0e5e742f6a299acf7aa8d2e5cfca946bcff224383fd451d894e79499f0a46"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7a626637c1d967e3e504ced353f89c2a9f6c8b4b4dbf348fdd3e1daa947a23c"}, - {file = "Levenshtein-0.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:88d8a13cf310cfc893e3734f8e7e42ef20c52780506e9bdb96e76a8b75e3ba20"}, - {file = "Levenshtein-0.23.0.tar.gz", hash = "sha256:de7ccc31a471ea5bfafabe804c12a63e18b4511afc1014f23c3cc7be8c70d3bd"}, + {file = "Levenshtein-0.24.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5bff001ba5edfcf632560e0843d9f1ccab874a8776c30025bbdad4345891b4c9"}, + {file = "Levenshtein-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:55e4676a5b6cde331ce3483cd7862dcd58f4fb3c4d2eded1934b00c176320324"}, + {file = "Levenshtein-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6f7fb893187ef35d911490d6b9dbf7b0ed21b4c1f31468a2f1d7980b37f182eb"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a475a61eed02e8bd07f779d3246f987a4330d4ad16d117e08dff2dba091984d9"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:042ad920544dbed0b29950b52bd24170aa8c4a01f5046f485e530a483e9af1c0"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e856b22f570991f2e9f3c82ab349aeb90843876636b13edcf770b946f56fbfe"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1fbf7113aa71483053cd927741aa89957c8d1cce1757f00dfcb9c609c9317b8"}, + {file = "Levenshtein-0.24.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81d542ebb10a2e83608c3cb57500f348f0f300cf9bd64b108eb34029b18351d9"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:951b07179ba6cdd903e0f63027661034a29a568197d59473da9f68d4d97761d8"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d44e983a5c4120da921d892050e32e9c4ff6fc60d206ce44d1bcc65cbff51f2f"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c440a27ee1bfe63ffd7caa228b75ffac82ba2338473197a8826026e979b9281b"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:dccc42d90924b1488a69b9bdd3250c6f977e4cca9cff664fb8734956a01cca34"}, + {file = "Levenshtein-0.24.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf3152ce6923f5827bf4b24ab9be8bb2a8576a0b656b1aca47ed262e308c4dd3"}, + {file = "Levenshtein-0.24.0-cp310-cp310-win32.whl", hash = "sha256:18431eef98194254dda8e8e91f9abdea4f9ceeeda729aff2ba7ab9980dc3d984"}, + {file = "Levenshtein-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:d9feef328de5f39b68795bbe7d5b9fc92b95d34a3720614c1684a1b991a0aa4f"}, + {file = "Levenshtein-0.24.0-cp310-cp310-win_arm64.whl", hash = "sha256:2c12be1e026496d7a261edc2fff2ddc05b36484cf00b9ae0f6b1f4b33d2d1775"}, + {file = "Levenshtein-0.24.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7614d077c9c0e9eeadc6923f589e8ce4c50d5277012d35928c747f5b35b9d535"}, + {file = "Levenshtein-0.24.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:23b6712bbcc844f922b148463485f1de34f3c69bcd1e7df664c477718526b933"}, + {file = "Levenshtein-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fcb5f941cc94b339cfbf1edd6a077c214cf30f93920552b44a4515b7d2b5b40"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f611755384ec658cc4dd8ebe2d1fd8def57d9ea89685f31341ca928b9eac674"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c003e5e8140bf31cc487283ed3c7edd7444aceedf9980388661bc594f87d1244"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:44fd4b3f88af58edcfba35b12f189c2e9380f3a48fecc8707c1511ac7acf8757"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fcf49de3ea0a1fc53f58878b6330f14779f1da2c5a69eb5e1f8d0b18a2f0bbb"}, + {file = "Levenshtein-0.24.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:598b4dfb7b95914fb8389acfc97b94373ba4a2d1756d2f9711e9d9113eeaa436"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0da21e303bda4887c5e4d9b0b24424f50d963ffdab79c456e1b47e8f0cd6141e"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0134c7c0942d9d89923f5b20e437a4dc00ff448e47100d3da254518bea348fc9"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:58e14117c86b7c33315e448610ab70ba34f41476b6784141aeabff5cf90a5736"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:8982669a679bee243dc813551254b31ecc148ee230757c9e6179f85f3e4f3cf2"}, + {file = "Levenshtein-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0727e03527a952ead76727304d8ec673636a25ce0d867d81bf04c652f4a928e2"}, + {file = "Levenshtein-0.24.0-cp311-cp311-win32.whl", hash = "sha256:80aa2c01ac5207ff005d3ea4ab5d65ca7e00d9b9a97893ca10fa6546317893a9"}, + {file = "Levenshtein-0.24.0-cp311-cp311-win_amd64.whl", hash = "sha256:4ba8b9c3fd5083a7d8a5b28423a083a116697e53eac23f2b85804353f9bbaaee"}, + {file = "Levenshtein-0.24.0-cp311-cp311-win_arm64.whl", hash = "sha256:0d8f275016b606ef85f7b5ac00b3f82779ef894e760a5da4c57867dab7affb73"}, + {file = "Levenshtein-0.24.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:98bd3aa83551d22c18b101bf820bd72f6ee3f71be6ae4ac4eeb1d232d2f05a87"}, + {file = "Levenshtein-0.24.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ca03ea4609a0ec3a06a57dff7e4311c690f7b8281af6062c23ebd79dfa8961a3"}, + {file = "Levenshtein-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a799395d22267ebbc591407eed3e3cb62d9f24cba33e3d3dfec28969d14865c5"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24fb6e42f8c411b052cbfa745684b7beffc27c2a16d8be46d4beee151c657898"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13ed45e77b2f61eb081a3130cb758589fa53f56a3c76a14bf5230eb7ade9ee61"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6b7a01bc434ce87e2785da51d8c568801afef5112215b5d6bb6e0c589cacb04"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f1ba9a84dae8a4d36dfc3fc81eaee41cf5215fb1716f22c1a6b0c6878d2fa4"}, + {file = "Levenshtein-0.24.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddc2ccb91a35271b625bf05fc8cb350cc531df00d1f7e3b79a752eaf142b3425"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2af8eca734dc067ad8806d73f8b4596eb5c8642953b6e83e9fcc18f85d9d664c"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5ef80249ace0c9c927db0057712ea6725b79576c747e282d59bd190c446eb19c"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:214504d8bae60f9cd654362ed59b19c5c1f569e2cd0dbf38494c0ea2e5576d11"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:3eb5ccd0b0f6453ca0d775c36eb687a3ff7d4239fa37f7d5384ca1805b7c278f"}, + {file = "Levenshtein-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de13a0c8aa49cbb436e9ddb65c96f79814da89250f7994494753d5122cb85e97"}, + {file = "Levenshtein-0.24.0-cp312-cp312-win32.whl", hash = "sha256:c91130765011e880e94430f5cd72ca855f429a5e42a0813718a4b2145ab8da26"}, + {file = "Levenshtein-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:c3fb6647a4f8430088619825628f594348a14e7b4be1cf4de3e72494c1104552"}, + {file = "Levenshtein-0.24.0-cp312-cp312-win_arm64.whl", hash = "sha256:c66bfad51e71d4df8dbafe56bd8f3e49554c31cdd86839e735ba02d943b764f7"}, + {file = "Levenshtein-0.24.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:25bb9ecfe1b9cdfbf0fda650a3cf0a4137a070164f8a1b983b1baecf97da6d93"}, + {file = "Levenshtein-0.24.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:770aeae22c181402e9bc9e10c8fee24d70cc4ec7f38cd1f2c5aedacddee157a0"}, + {file = "Levenshtein-0.24.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e713a10982c944c4a92a62cbedcd65058511fd33104385adff863c9eaac3882e"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a4d514b8b30d7cc1e8b15e81b9695d5f30f39952f257e1e8f24b838e6b102a"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec9aa857b96f1b0e18af641b56329932e6bb04bf2ce99bc8c8f3b2820b7b704b"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40dcf41927a48aeba1c879ab9794113b214f9275b6372d0bb10ee2f07c94fb68"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc66309c19ecd12dace6170746c449e51d266ef3243673d21f221d8e464cb683"}, + {file = "Levenshtein-0.24.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0833836a5741bfceb67c710bd6368b5a5315da9a8950bda5ffbd8ded1aace56e"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aeae28552a6aedaf6cd7c4111a15b22f3246bf885fb918f420995cabe21058cc"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f32f8e77a1ce17b0cb502bdbf7756ff204124d0b37ad091bcaa117e0926c5c82"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:37ce96068ce24423ddb869adb2a6562630652868698932fe2e0e02ccc5d8f56e"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:780644075108e349f58cfd3ecbb917598186f08ca366f106f05f8a8d2822454a"}, + {file = "Levenshtein-0.24.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3ef431af9bcb4fd79bbf44f9b16b755ea74ecfcc5ec5727904c4544069b9cf82"}, + {file = "Levenshtein-0.24.0-cp38-cp38-win32.whl", hash = "sha256:a2cb7b1cbbab0810f80eda12897e5c5a2cffc07f279e115dcf293863311d72b2"}, + {file = "Levenshtein-0.24.0-cp38-cp38-win_amd64.whl", hash = "sha256:6b1a704559893efd217d1c59c20d980b97551480cf646961362f5a07206eb80a"}, + {file = "Levenshtein-0.24.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a564b93fb845b1b1caf03628357300d3b228be17f5bd8f338d470a91b2963989"}, + {file = "Levenshtein-0.24.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8de6fe37c9be5da21f13345a0c57f3ebdf8b6d9f3d8e8e326541b1b2566f6421"}, + {file = "Levenshtein-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:559dca81c5717d4ac1ffdc6959d007cf2914194f27afdaa5907ded6680720537"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf164f896e6ee9b6dd03a0173a299cda8137dbd625baeb441a1b00197e71eb4"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd815f679c05225e667148cd702632552d56d55cc6c215ca8f1147e4c5f05f98"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c8dc9346d016638ecb565fa00bf9227d14047c60acc38d9aba8f17b49c44809"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e7765f2793af58dfc34949e21378c181374bb7e95787bf8dd39da14e37bc18f"}, + {file = "Levenshtein-0.24.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a3eaedf4b0f5ae0c9d798bb1debea27ee555733048610ce83dd89511d469a52"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ce6a3cb5e50c2028fa91a5ab3418b194cb8596cedd859d1bab5b8ca8ea56ab19"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f319af67483978f8536eaa3abbefe540d1d1e59729fcb882a3126a311480f0cd"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:aaf7fedae112417b3015ad237c30fbbb7787cbc4dd53c886c6118439b1ae1314"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:57df7e6b5f719bc53be27f1b932437608e25f2fbc75aa6e14e9696abf023b3b2"}, + {file = "Levenshtein-0.24.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c85a3c172cf49935c41c9132e511836a489febc3a557ad80ed2c1ebcc6f3ab0e"}, + {file = "Levenshtein-0.24.0-cp39-cp39-win32.whl", hash = "sha256:a93ae01e1c2dc1f5ca42e4597a25fc4544afb2a580c61f13d4716314d7ff3480"}, + {file = "Levenshtein-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:c228b8e3be9913deab16bf4f17a07b99034df58a3b0e161ad04995694b7dfda2"}, + {file = "Levenshtein-0.24.0-cp39-cp39-win_arm64.whl", hash = "sha256:75aa294bfd4f43373c76e792296eb45a2ca6477937196a03779b620d3276673f"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:15c4a6dc9c25cfb02ae96031702ce88ff25ed9e3dd7357bb3ac89c13f4faa50d"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d3f811cac76099f1f329ec10ac6b8b5402a1e202393f561c47c33c4c610b4f1"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678aca306e1811e4cf57e6b8dd2041785f087e459d2dd9ec1a68e691fe595cb5"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3e4c2f6fe12f33f895ade893151a3b1356bfbb41499a5249063ab73b59296f0"}, + {file = "Levenshtein-0.24.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8cd4647eff7ffc43a46fea9fecbe312980bf4bf2fd73bc11f0f1f4567f17143f"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5d0b8d44a9666ac28badecf938bb069b4ebd2a4c0e7fcfd80b56c856bb9251c7"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac823793cbd850984740c7e2d553ebd2684f4404045a4f87af23c48dd62dc3ab"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f81cb7582b6bc263ba29a5960bfac82a8967ae94923e23f496db500a0da28fe2"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf0d19ae6f76e1048a44298b394f897910723ae732eb8c98c705a6eba02138e"}, + {file = "Levenshtein-0.24.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:12c915d89ba3ade46877c351ea48c6eec1f3f53af1fe2d57b8715e18e686940e"}, + {file = "Levenshtein-0.24.0.tar.gz", hash = "sha256:0cbcf3c9a7c77de3a405bfc857ab94341b4049e8c5c6b917f5ffcd5a92ff169a"}, ] [package.dependencies] @@ -2577,19 +2559,19 @@ files = [ [[package]] name = "palace-webpub-manifest-parser" -version = "3.1.0" +version = "3.1.1" description = "A parser for the Readium Web Publication Manifest, OPDS 2.0 and ODL formats." optional = false python-versions = ">=3.8,<4" files = [ - {file = "palace_webpub_manifest_parser-3.1.0-py3-none-any.whl", hash = "sha256:2d65fbfddafd70d0e571d8e0cee4ad726baf187180742bbab026ef9066068b5c"}, - {file = "palace_webpub_manifest_parser-3.1.0.tar.gz", hash = "sha256:9ca52be816ade5812e4f2cc1a3bd0892ba10c16f8497896aed43038ad831ee02"}, + {file = "palace_webpub_manifest_parser-3.1.1-py3-none-any.whl", hash = "sha256:ac43d7f16414810cf7aeea26b9825ae8678404887ecf7a0345aa47ad992510d8"}, + {file = "palace_webpub_manifest_parser-3.1.1.tar.gz", hash = "sha256:7025164e2ae997371ed355355d8321685c6eb1228b86d10430e682d7316351b3"}, ] [package.dependencies] jsonschema = ">=4.19,<5.0" multipledispatch = ">=1.0,<2.0" -pyrsistent = ">=0.19,<0.20" +pyrsistent = ">=0.20,<0.21" python-dateutil = ">=2.8,<3.0" pytz = ">=2023.3,<2024.0" requests = ">=2.27,<3.0" @@ -2945,47 +2927,47 @@ files = [ [[package]] name = "pydantic" -version = "1.10.13" +version = "1.10.14" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic-1.10.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efff03cc7a4f29d9009d1c96ceb1e7a70a65cfe86e89d34e4a5f2ab1e5693737"}, - {file = "pydantic-1.10.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ecea2b9d80e5333303eeb77e180b90e95eea8f765d08c3d278cd56b00345d01"}, - {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1740068fd8e2ef6eb27a20e5651df000978edce6da6803c2bef0bc74540f9548"}, - {file = "pydantic-1.10.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84bafe2e60b5e78bc64a2941b4c071a4b7404c5c907f5f5a99b0139781e69ed8"}, - {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bc0898c12f8e9c97f6cd44c0ed70d55749eaf783716896960b4ecce2edfd2d69"}, - {file = "pydantic-1.10.13-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:654db58ae399fe6434e55325a2c3e959836bd17a6f6a0b6ca8107ea0571d2e17"}, - {file = "pydantic-1.10.13-cp310-cp310-win_amd64.whl", hash = "sha256:75ac15385a3534d887a99c713aa3da88a30fbd6204a5cd0dc4dab3d770b9bd2f"}, - {file = "pydantic-1.10.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c553f6a156deb868ba38a23cf0df886c63492e9257f60a79c0fd8e7173537653"}, - {file = "pydantic-1.10.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e08865bc6464df8c7d61439ef4439829e3ab62ab1669cddea8dd00cd74b9ffe"}, - {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31647d85a2013d926ce60b84f9dd5300d44535a9941fe825dc349ae1f760df9"}, - {file = "pydantic-1.10.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:210ce042e8f6f7c01168b2d84d4c9eb2b009fe7bf572c2266e235edf14bacd80"}, - {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8ae5dd6b721459bfa30805f4c25880e0dd78fc5b5879f9f7a692196ddcb5a580"}, - {file = "pydantic-1.10.13-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0"}, - {file = "pydantic-1.10.13-cp311-cp311-win_amd64.whl", hash = "sha256:61d9dce220447fb74f45e73d7ff3b530e25db30192ad8d425166d43c5deb6df0"}, - {file = "pydantic-1.10.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4b03e42ec20286f052490423682016fd80fda830d8e4119f8ab13ec7464c0132"}, - {file = "pydantic-1.10.13-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5"}, - {file = "pydantic-1.10.13-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a1f9f747851338933942db7af7b6ee8268568ef2ed86c4185c6ef4402e80ba8"}, - {file = "pydantic-1.10.13-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:97cce3ae7341f7620a0ba5ef6cf043975cd9d2b81f3aa5f4ea37928269bc1b87"}, - {file = "pydantic-1.10.13-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:854223752ba81e3abf663d685f105c64150873cc6f5d0c01d3e3220bcff7d36f"}, - {file = "pydantic-1.10.13-cp37-cp37m-win_amd64.whl", hash = "sha256:b97c1fac8c49be29486df85968682b0afa77e1b809aff74b83081cc115e52f33"}, - {file = "pydantic-1.10.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c958d053453a1c4b1c2062b05cd42d9d5c8eb67537b8d5a7e3c3032943ecd261"}, - {file = "pydantic-1.10.13-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c5370a7edaac06daee3af1c8b1192e305bc102abcbf2a92374b5bc793818599"}, - {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6f6e7305244bddb4414ba7094ce910560c907bdfa3501e9db1a7fd7eaea127"}, - {file = "pydantic-1.10.13-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3a3c792a58e1622667a2837512099eac62490cdfd63bd407993aaf200a4cf1f"}, - {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c636925f38b8db208e09d344c7aa4f29a86bb9947495dd6b6d376ad10334fb78"}, - {file = "pydantic-1.10.13-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:678bcf5591b63cc917100dc50ab6caebe597ac67e8c9ccb75e698f66038ea953"}, - {file = "pydantic-1.10.13-cp38-cp38-win_amd64.whl", hash = "sha256:6cf25c1a65c27923a17b3da28a0bdb99f62ee04230c931d83e888012851f4e7f"}, - {file = "pydantic-1.10.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8ef467901d7a41fa0ca6db9ae3ec0021e3f657ce2c208e98cd511f3161c762c6"}, - {file = "pydantic-1.10.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968ac42970f57b8344ee08837b62f6ee6f53c33f603547a55571c954a4225691"}, - {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9849f031cf8a2f0a928fe885e5a04b08006d6d41876b8bbd2fc68a18f9f2e3fd"}, - {file = "pydantic-1.10.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56e3ff861c3b9c6857579de282ce8baabf443f42ffba355bf070770ed63e11e1"}, - {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f00790179497767aae6bcdc36355792c79e7bbb20b145ff449700eb076c5f96"}, - {file = "pydantic-1.10.13-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:75b297827b59bc229cac1a23a2f7a4ac0031068e5be0ce385be1462e7e17a35d"}, - {file = "pydantic-1.10.13-cp39-cp39-win_amd64.whl", hash = "sha256:e70ca129d2053fb8b728ee7d1af8e553a928d7e301a311094b8a0501adc8763d"}, - {file = "pydantic-1.10.13-py3-none-any.whl", hash = "sha256:b87326822e71bd5f313e7d3bfdc77ac3247035ac10b0c0618bd99dcf95b1e687"}, - {file = "pydantic-1.10.13.tar.gz", hash = "sha256:32c8b48dcd3b2ac4e78b0ba4af3a2c2eb6048cb75202f0ea7b34feb740efc340"}, + {file = "pydantic-1.10.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4fcec873f90537c382840f330b90f4715eebc2bc9925f04cb92de593eae054"}, + {file = "pydantic-1.10.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e3a76f571970fcd3c43ad982daf936ae39b3e90b8a2e96c04113a369869dc87"}, + {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d886bd3c3fbeaa963692ef6b643159ccb4b4cefaf7ff1617720cbead04fd1d"}, + {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:798a3d05ee3b71967844a1164fd5bdb8c22c6d674f26274e78b9f29d81770c4e"}, + {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:23d47a4b57a38e8652bcab15a658fdb13c785b9ce217cc3a729504ab4e1d6bc9"}, + {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f9f674b5c3bebc2eba401de64f29948ae1e646ba2735f884d1594c5f675d6f2a"}, + {file = "pydantic-1.10.14-cp310-cp310-win_amd64.whl", hash = "sha256:24a7679fab2e0eeedb5a8924fc4a694b3bcaac7d305aeeac72dd7d4e05ecbebf"}, + {file = "pydantic-1.10.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d578ac4bf7fdf10ce14caba6f734c178379bd35c486c6deb6f49006e1ba78a7"}, + {file = "pydantic-1.10.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa7790e94c60f809c95602a26d906eba01a0abee9cc24150e4ce2189352deb1b"}, + {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad4e10efa5474ed1a611b6d7f0d130f4aafadceb73c11d9e72823e8f508e663"}, + {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1245f4f61f467cb3dfeced2b119afef3db386aec3d24a22a1de08c65038b255f"}, + {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:21efacc678a11114c765eb52ec0db62edffa89e9a562a94cbf8fa10b5db5c046"}, + {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:412ab4a3f6dbd2bf18aefa9f79c7cca23744846b31f1d6555c2ee2b05a2e14ca"}, + {file = "pydantic-1.10.14-cp311-cp311-win_amd64.whl", hash = "sha256:e897c9f35281f7889873a3e6d6b69aa1447ceb024e8495a5f0d02ecd17742a7f"}, + {file = "pydantic-1.10.14-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d604be0f0b44d473e54fdcb12302495fe0467c56509a2f80483476f3ba92b33c"}, + {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a42c7d17706911199798d4c464b352e640cab4351efe69c2267823d619a937e5"}, + {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:596f12a1085e38dbda5cbb874d0973303e34227b400b6414782bf205cc14940c"}, + {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bfb113860e9288d0886e3b9e49d9cf4a9d48b441f52ded7d96db7819028514cc"}, + {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bc3ed06ab13660b565eed80887fcfbc0070f0aa0691fbb351657041d3e874efe"}, + {file = "pydantic-1.10.14-cp37-cp37m-win_amd64.whl", hash = "sha256:ad8c2bc677ae5f6dbd3cf92f2c7dc613507eafe8f71719727cbc0a7dec9a8c01"}, + {file = "pydantic-1.10.14-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c37c28449752bb1f47975d22ef2882d70513c546f8f37201e0fec3a97b816eee"}, + {file = "pydantic-1.10.14-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49a46a0994dd551ec051986806122767cf144b9702e31d47f6d493c336462597"}, + {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53e3819bd20a42470d6dd0fe7fc1c121c92247bca104ce608e609b59bc7a77ee"}, + {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbb503bbbbab0c588ed3cd21975a1d0d4163b87e360fec17a792f7d8c4ff29f"}, + {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:336709883c15c050b9c55a63d6c7ff09be883dbc17805d2b063395dd9d9d0022"}, + {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4ae57b4d8e3312d486e2498d42aed3ece7b51848336964e43abbf9671584e67f"}, + {file = "pydantic-1.10.14-cp38-cp38-win_amd64.whl", hash = "sha256:dba49d52500c35cfec0b28aa8b3ea5c37c9df183ffc7210b10ff2a415c125c4a"}, + {file = "pydantic-1.10.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c66609e138c31cba607d8e2a7b6a5dc38979a06c900815495b2d90ce6ded35b4"}, + {file = "pydantic-1.10.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d986e115e0b39604b9eee3507987368ff8148222da213cd38c359f6f57b3b347"}, + {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:646b2b12df4295b4c3148850c85bff29ef6d0d9621a8d091e98094871a62e5c7"}, + {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282613a5969c47c83a8710cc8bfd1e70c9223feb76566f74683af889faadc0ea"}, + {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:466669501d08ad8eb3c4fecd991c5e793c4e0bbd62299d05111d4f827cded64f"}, + {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:13e86a19dca96373dcf3190fcb8797d40a6f12f154a244a8d1e8e03b8f280593"}, + {file = "pydantic-1.10.14-cp39-cp39-win_amd64.whl", hash = "sha256:08b6ec0917c30861e3fe71a93be1648a2aa4f62f866142ba21670b24444d7fd8"}, + {file = "pydantic-1.10.14-py3-none-any.whl", hash = "sha256:8ee853cd12ac2ddbf0ecbac1c289f95882b2d4482258048079d13be700aa114c"}, + {file = "pydantic-1.10.14.tar.gz", hash = "sha256:46f17b832fe27de7850896f3afee50ea682220dd218f7e9c88d436788419dca6"}, ] [package.dependencies] @@ -2999,82 +2981,82 @@ email = ["email-validator (>=1.0.3)"] [[package]] name = "pyfakefs" -version = "5.3.2" +version = "5.3.4" description = "pyfakefs implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" files = [ - {file = "pyfakefs-5.3.2-py3-none-any.whl", hash = "sha256:5a62194cfa24542a3c9080b66ce65d78b2e977957edfd3cd6fe98e8349bcca32"}, - {file = "pyfakefs-5.3.2.tar.gz", hash = "sha256:a83776a3c1046d4d103f2f530029aa6cdff5f0386dffd59c15ee16926135493c"}, + {file = "pyfakefs-5.3.4-py3-none-any.whl", hash = "sha256:fc375229f5417f197f0892a7d6dc49a411e67e10eb8142b19d80e60a9d52a13d"}, + {file = "pyfakefs-5.3.4.tar.gz", hash = "sha256:dadac1653195a4bfe4c26e9dfa7cc0c0286b1cd8e18706442c2464cae5542a17"}, ] [[package]] name = "pyinstrument" -version = "4.6.1" +version = "4.6.2" description = "Call stack profiler for Python. Shows you why your code is slow!" optional = false python-versions = ">=3.7" files = [ - {file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:73476e4bc6e467ac1b2c3c0dd1f0b71c9061d4de14626676adfdfbb14aa342b4"}, - {file = "pyinstrument-4.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4d1da8efd974cf9df52ee03edaee2d3875105ddd00de35aa542760f7c612bdf7"}, - {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507be1ee2f2b0c9fba74d622a272640dd6d1b0c9ec3388b2cdeb97ad1e77125f"}, - {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cee6de08eb45754ef4f602ce52b640d1c535d934a6a8733a974daa095def37"}, - {file = "pyinstrument-4.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7873e8cec92321251fdf894a72b3c78f4c5c20afdd1fef0baf9042ec843bb04"}, - {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a242f6cac40bc83e1f3002b6b53681846dfba007f366971db0bf21e02dbb1903"}, - {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:97c9660cdb4bd2a43cf4f3ab52cffd22f3ac9a748d913b750178fb34e5e39e64"}, - {file = "pyinstrument-4.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e304cd0723e2b18ada5e63c187abf6d777949454c734f5974d64a0865859f0f4"}, - {file = "pyinstrument-4.6.1-cp310-cp310-win32.whl", hash = "sha256:cee21a2d78187dd8a80f72f5d0f1ddb767b2d9800f8bb4d94b6d11f217c22cdb"}, - {file = "pyinstrument-4.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:2000712f71d693fed2f8a1c1638d37b7919124f367b37976d07128d49f1445eb"}, - {file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a366c6f3dfb11f1739bdc1dee75a01c1563ad0bf4047071e5e77598087df457f"}, - {file = "pyinstrument-4.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6be327be65d934796558aa9cb0f75ce62ebd207d49ad1854610c97b0579ad47"}, - {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e160d9c5d20d3e4ef82269e4e8b246ff09bdf37af5fb8cb8ccca97936d95ad6"}, - {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ffbf56605ef21c2fcb60de2fa74ff81f417d8be0c5002a407e414d6ef6dee43"}, - {file = "pyinstrument-4.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c92cc4924596d6e8f30a16182bbe90893b1572d847ae12652f72b34a9a17c24a"}, - {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f4b48a94d938cae981f6948d9ec603bab2087b178d2095d042d5a48aabaecaab"}, - {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7a386392275bdef4a1849712dc5b74f0023483fca14ef93d0ca27d453548982"}, - {file = "pyinstrument-4.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:871b131b83e9b1122f2325061c68ed1e861eebcb568c934d2fb193652f077f77"}, - {file = "pyinstrument-4.6.1-cp311-cp311-win32.whl", hash = "sha256:8d8515156dd91f5652d13b5fcc87e634f8fe1c07b68d1d0840348cdd50bf5ace"}, - {file = "pyinstrument-4.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb868fbe089036e9f32525a249f4c78b8dc46967612393f204b8234f439c9cc4"}, - {file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a18cd234cce4f230f1733807f17a134e64a1f1acabf74a14d27f583cf2b183df"}, - {file = "pyinstrument-4.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:574cfca69150be4ce4461fb224712fbc0722a49b0dc02fa204d02807adf6b5a0"}, - {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e02cf505e932eb8ccf561b7527550a67ec14fcae1fe0e25319b09c9c166e914"}, - {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832fb2acef9d53701c1ab546564c45fb70a8770c816374f8dd11420d399103c9"}, - {file = "pyinstrument-4.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13cb57e9607545623ebe462345b3d0c4caee0125d2d02267043ece8aca8f4ea0"}, - {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9be89e7419bcfe8dd6abb0d959d6d9c439c613a4a873514c43d16b48dae697c9"}, - {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:476785cfbc44e8e1b1ad447398aa3deae81a8df4d37eb2d8bbb0c404eff979cd"}, - {file = "pyinstrument-4.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e9cebd90128a3d2fee36d3ccb665c1b9dce75261061b2046203e45c4a8012d54"}, - {file = "pyinstrument-4.6.1-cp312-cp312-win32.whl", hash = "sha256:1d0b76683df2ad5c40eff73607dc5c13828c92fbca36aff1ddf869a3c5a55fa6"}, - {file = "pyinstrument-4.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:c4b7af1d9d6a523cfbfedebcb69202242d5bd0cb89c4e094cc73d5d6e38279bd"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79ae152f8c6a680a188fb3be5e0f360ac05db5bbf410169a6c40851dfaebcce9"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07cad2745964c174c65aa75f1bf68a4394d1b4d28f33894837cfd315d1e836f0"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb81f66f7f94045d723069cf317453d42375de9ff3c69089cf6466b078ac1db4"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab30ae75969da99e9a529e21ff497c18fdf958e822753db4ae7ed1e67094040"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f36cb5b644762fb3c86289324bbef17e95f91cd710603ac19444a47f638e8e96"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8b45075d9dbbc977dbc7007fb22bb0054c6990fbe91bf48dd80c0b96c6307ba7"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:475ac31477f6302e092463896d6a2055f3e6abcd293bad16ff94fc9185308a88"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-win32.whl", hash = "sha256:29172ab3d8609fdf821c3f2562dc61e14f1a8ff5306607c32ca743582d3a760e"}, - {file = "pyinstrument-4.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:bd176f297c99035127b264369d2bb97a65255f65f8d4e843836baf55ebb3cee4"}, - {file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:23e9b4526978432e9999021da9a545992cf2ac3df5ee82db7beb6908fc4c978c"}, - {file = "pyinstrument-4.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2dbcaccc9f456ef95557ec501caeb292119c24446d768cb4fb43578b0f3d572c"}, - {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2097f63c66c2bc9678c826b9ff0c25acde3ed455590d9dcac21220673fe74fbf"}, - {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:205ac2e76bd65d61b9611a9ce03d5f6393e34ec5b41dd38808f25d54e6b3e067"}, - {file = "pyinstrument-4.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f414ddf1161976a40fc0a333000e6a4ad612719eac0b8c9bb73f47153187148"}, - {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:65e62ebfa2cd8fb57eda90006f4505ac4c70da00fc2f05b6d8337d776ea76d41"}, - {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d96309df4df10be7b4885797c5f69bb3a89414680ebaec0722d8156fde5268c3"}, - {file = "pyinstrument-4.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f3d1ad3bc8ebb4db925afa706aa865c4bfb40d52509f143491ac0df2440ee5d2"}, - {file = "pyinstrument-4.6.1-cp38-cp38-win32.whl", hash = "sha256:dc37cb988c8854eb42bda2e438aaf553536566657d157c4473cc8aad5692a779"}, - {file = "pyinstrument-4.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:2cd4ce750c34a0318fc2d6c727cc255e9658d12a5cf3f2d0473f1c27157bdaeb"}, - {file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ca95b21f022e995e062b371d1f42d901452bcbedd2c02f036de677119503355"}, - {file = "pyinstrument-4.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ac1e1d7e1f1b64054c4eb04eb4869a7a5eef2261440e73943cc1b1bc3c828c18"}, - {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0711845e953fce6ab781221aacffa2a66dbc3289f8343e5babd7b2ea34da6c90"}, - {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b7d28582017de35cb64eb4e4fa603e753095108ca03745f5d17295970ee631f"}, - {file = "pyinstrument-4.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7be57db08bd366a37db3aa3a6187941ee21196e8b14975db337ddc7d1490649d"}, - {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9a0ac0f56860398d2628ce389826ce83fb3a557d0c9a2351e8a2eac6eb869983"}, - {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a9045186ff13bc826fef16be53736a85029aae3c6adfe52e666cad00d7ca623b"}, - {file = "pyinstrument-4.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6c4c56b6eab9004e92ad8a48bb54913fdd71fc8a748ae42a27b9e26041646f8b"}, - {file = "pyinstrument-4.6.1-cp39-cp39-win32.whl", hash = "sha256:37e989c44b51839d0c97466fa2b623638b9470d56d79e329f359f0e8fa6d83db"}, - {file = "pyinstrument-4.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:5494c5a84fee4309d7d973366ca6b8b9f8ba1d6b254e93b7c506264ef74f2cef"}, - {file = "pyinstrument-4.6.1.tar.gz", hash = "sha256:f4731b27121350f5a983d358d2272fe3df2f538aed058f57217eef7801a89288"}, + {file = "pyinstrument-4.6.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7a1b1cd768ea7ea9ab6f5490f7e74431321bcc463e9441dbc2f769617252d9e2"}, + {file = "pyinstrument-4.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a386b9d09d167451fb2111eaf86aabf6e094fed42c15f62ec51d6980bce7d96"}, + {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23c3e3ca8553b9aac09bd978c73d21b9032c707ac6d803bae6a20ecc048df4a8"}, + {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f329f5534ca069420246f5ce57270d975229bcb92a3a3fd6b2ca086527d9764"}, + {file = "pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4dcdcc7ba224a0c5edfbd00b0f530f5aed2b26da5aaa2f9af5519d4aa8c7e41"}, + {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73db0c2c99119c65b075feee76e903b4ed82e59440fe8b5724acf5c7cb24721f"}, + {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:da58f265326f3cf3975366ccb8b39014f1e69ff8327958a089858d71c633d654"}, + {file = "pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:feebcf860f955401df30d029ec8de7a0c5515d24ea809736430fd1219686fe14"}, + {file = "pyinstrument-4.6.2-cp310-cp310-win32.whl", hash = "sha256:b2b66ff0b16c8ecf1ec22de001cfff46872b2c163c62429055105564eef50b2e"}, + {file = "pyinstrument-4.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:8d104b7a7899d5fa4c5bf1ceb0c1a070615a72c5dc17bc321b612467ad5c5d88"}, + {file = "pyinstrument-4.6.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:62f6014d2b928b181a52483e7c7b82f2c27e22c577417d1681153e5518f03317"}, + {file = "pyinstrument-4.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5c8d763c5df55131670ba2a01a8aebd0d490a789904a55eb6a8b8d497f110"}, + {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ed4e8c6c84e0e6429ba7008a66e435ede2d8cb027794c20923c55669d9c5633"}, + {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c0f0e1d8f8c70faa90ff57f78ac0dda774b52ea0bfb2d9f0f41ce6f3e7c869e"}, + {file = "pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3c44cb037ad0d6e9d9a48c14d856254ada641fbd0ae9de40da045fc2226a2a"}, + {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:be9901f17ac2f527c352f2fdca3d717c1d7f2ce8a70bad5a490fc8cc5d2a6007"}, + {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a9791bf8916c1cf439c202fded32de93354b0f57328f303d71950b0027c7811"}, + {file = "pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d6162615e783c59e36f2d7caf903a7e3ecb6b32d4a4ae8907f2760b2ef395bf6"}, + {file = "pyinstrument-4.6.2-cp311-cp311-win32.whl", hash = "sha256:28af084aa84bbfd3620ebe71d5f9a0deca4451267f363738ca824f733de55056"}, + {file = "pyinstrument-4.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:dd6007d3c2e318e09e582435dd8d111cccf30d342af66886b783208813caf3d7"}, + {file = "pyinstrument-4.6.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e3813c8ecfab9d7d855c5f0f71f11793cf1507f40401aa33575c7fd613577c23"}, + {file = "pyinstrument-4.6.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6c761372945e60fc1396b7a49f30592e8474e70a558f1a87346d27c8c4ce50f7"}, + {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fba3244e94c117bf4d9b30b8852bbdcd510e7329fdd5c7c8b3799e00a9215a8"}, + {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:803ac64e526473d64283f504df3b0d5c2c203ea9603cab428641538ffdc753a7"}, + {file = "pyinstrument-4.6.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2e554b1bb0df78f5ce8a92df75b664912ca93aa94208386102af454ec31b647"}, + {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7c671057fad22ee3ded897a6a361204ea2538e44c1233cad0e8e30f6d27f33db"}, + {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d02f31fa13a9e8dc702a113878419deba859563a32474c9f68e04619d43d6f01"}, + {file = "pyinstrument-4.6.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b55983a884f083f93f0fc6d12ff8df0acd1e2fb0580d2f4c7bfe6def33a84b58"}, + {file = "pyinstrument-4.6.2-cp312-cp312-win32.whl", hash = "sha256:fdc0a53b27e5d8e47147489c7dab596ddd1756b1e053217ef5bc6718567099ff"}, + {file = "pyinstrument-4.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:dd5c53a0159126b5ce7cbc4994433c9c671e057c85297ff32645166a06ad2c50"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b082df0bbf71251a7f4880a12ed28421dba84ea7110bb376e0533067a4eaff40"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90350533396071cb2543affe01e40bf534c35cb0d4b8fa9fdb0f052f9ca2cfe3"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67268bb0d579330cff40fd1c90b8510363ca1a0e7204225840614068658dab77"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20e15b4e1d29ba0b7fc81aac50351e0dc0d7e911e93771ebc3f408e864a2c93b"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2e625fc6ffcd4fd420493edd8276179c3f784df207bef4c2192725c1b310534c"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:113d2fc534c9ca7b6b5661d6ada05515bf318f6eb34e8d05860fe49eb7cfe17e"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3098cd72b71a322a72dafeb4ba5c566465e193d2030adad4c09566bd2f89bf4f"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-win32.whl", hash = "sha256:08fdc7f88c989316fa47805234c37a40fafe7b614afd8ae863f0afa9d1707b37"}, + {file = "pyinstrument-4.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5ebeba952c0056dcc9b9355328c78c4b5c2a33b4b4276a9157a3ab589f3d1bac"}, + {file = "pyinstrument-4.6.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:34e59e91c88ec9ad5630c0964eca823949005e97736bfa838beb4789e94912a2"}, + {file = "pyinstrument-4.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cd0320c39e99e3c0a3129d1ed010ac41e5a7eb96fb79900d270080a97962e995"}, + {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46992e855d630575ec635eeca0068a8ddf423d4fd32ea0875a94e9f8688f0b95"}, + {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e474c56da636253dfdca7cd1998b240d6b39f7ed34777362db69224fcf053b1"}, + {file = "pyinstrument-4.6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b559322f30509ad8f082561792352d0805b3edfa508e492a36041fdc009259"}, + {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:06a8578b2943eb1dbbf281e1e59e44246acfefd79e1b06d4950f01b693de12af"}, + {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7bd3da31c46f1c1cb7ae89031725f6a1d1015c2041d9c753fe23980f5f9fd86c"}, + {file = "pyinstrument-4.6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e63f4916001aa9c625976a50779282e0a5b5e9b17c52a50ef4c651e468ed5b88"}, + {file = "pyinstrument-4.6.2-cp38-cp38-win32.whl", hash = "sha256:32ec8db6896b94af790a530e1e0edad4d0f941a0ab8dd9073e5993e7ea46af7d"}, + {file = "pyinstrument-4.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:a59fc4f7db738a094823afe6422509fa5816a7bf74e768ce5a7a2ddd91af40ac"}, + {file = "pyinstrument-4.6.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3a165e0d2deb212d4cf439383982a831682009e1b08733c568cac88c89784e62"}, + {file = "pyinstrument-4.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ba858b3d6f6e5597c641edcc0e7e464f85aba86d71bc3b3592cb89897bf43f6"}, + {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fd8e547cf3df5f0ec6e4dffbe2e857f6b28eda51b71c3c0b5a2fc0646527835"}, + {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de2c1714a37a820033b19cf134ead43299a02662f1379140974a9ab733c5f3a"}, + {file = "pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01fc45dedceec3df81668d702bca6d400d956c8b8494abc206638c167c78dfd9"}, + {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5b6e161ef268d43ee6bbfae7fd2cdd0a52c099ddd21001c126ca1805dc906539"}, + {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6ba8e368d0421f15ba6366dfd60ec131c1b46505d021477e0f865d26cf35a605"}, + {file = "pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edca46f04a573ac2fb11a84b937844e6a109f38f80f4b422222fb5be8ecad8cb"}, + {file = "pyinstrument-4.6.2-cp39-cp39-win32.whl", hash = "sha256:baf375953b02fe94d00e716f060e60211ede73f49512b96687335f7071adb153"}, + {file = "pyinstrument-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:af1a953bce9fd530040895d01ff3de485e25e1576dccb014f76ba9131376fcad"}, + {file = "pyinstrument-4.6.2.tar.gz", hash = "sha256:0002ee517ed8502bbda6eb2bb1ba8f95a55492fcdf03811ba13d4806e50dd7f6"}, ] [package.extras] @@ -3138,17 +3120,17 @@ files = [ [[package]] name = "pyopenssl" -version = "23.3.0" +version = "24.0.0" description = "Python wrapper module around the OpenSSL library" optional = false python-versions = ">=3.7" files = [ - {file = "pyOpenSSL-23.3.0-py3-none-any.whl", hash = "sha256:6756834481d9ed5470f4a9393455154bc92fe7a64b7bc6ee2c804e78c52099b2"}, - {file = "pyOpenSSL-23.3.0.tar.gz", hash = "sha256:6b2cba5cc46e822750ec3e5a81ee12819850b11303630d575e98108a079c2b12"}, + {file = "pyOpenSSL-24.0.0-py3-none-any.whl", hash = "sha256:ba07553fb6fd6a7a2259adb9b84e12302a9a8a75c44046e8bb5d3e5ee887e3c3"}, + {file = "pyOpenSSL-24.0.0.tar.gz", hash = "sha256:6aa33039a93fffa4563e655b61d11364d01264be8ccb49906101e02a334530bf"}, ] [package.dependencies] -cryptography = ">=41.0.5,<42" +cryptography = ">=41.0.5,<43" [package.extras] docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] @@ -3203,49 +3185,54 @@ testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytes [[package]] name = "pyrsistent" -version = "0.19.3" +version = "0.20.0" description = "Persistent/Functional/Immutable data structures" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pyrsistent-0.19.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a"}, - {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64"}, - {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf"}, - {file = "pyrsistent-0.19.3-cp310-cp310-win32.whl", hash = "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a"}, - {file = "pyrsistent-0.19.3-cp310-cp310-win_amd64.whl", hash = "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da"}, - {file = "pyrsistent-0.19.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9"}, - {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393"}, - {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19"}, - {file = "pyrsistent-0.19.3-cp311-cp311-win32.whl", hash = "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3"}, - {file = "pyrsistent-0.19.3-cp311-cp311-win_amd64.whl", hash = "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-win32.whl", hash = "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1"}, - {file = "pyrsistent-0.19.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b"}, - {file = "pyrsistent-0.19.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8"}, - {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a"}, - {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c"}, - {file = "pyrsistent-0.19.3-cp38-cp38-win32.whl", hash = "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c"}, - {file = "pyrsistent-0.19.3-cp38-cp38-win_amd64.whl", hash = "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7"}, - {file = "pyrsistent-0.19.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc"}, - {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2"}, - {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3"}, - {file = "pyrsistent-0.19.3-cp39-cp39-win32.whl", hash = "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2"}, - {file = "pyrsistent-0.19.3-cp39-cp39-win_amd64.whl", hash = "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98"}, - {file = "pyrsistent-0.19.3-py3-none-any.whl", hash = "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64"}, - {file = "pyrsistent-0.19.3.tar.gz", hash = "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440"}, + {file = "pyrsistent-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b"}, + {file = "pyrsistent-0.20.0-cp310-cp310-win32.whl", hash = "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f"}, + {file = "pyrsistent-0.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7"}, + {file = "pyrsistent-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224"}, + {file = "pyrsistent-0.20.0-cp311-cp311-win32.whl", hash = "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656"}, + {file = "pyrsistent-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee"}, + {file = "pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d"}, + {file = "pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174"}, + {file = "pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d"}, + {file = "pyrsistent-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86"}, + {file = "pyrsistent-0.20.0-cp38-cp38-win32.whl", hash = "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423"}, + {file = "pyrsistent-0.20.0-cp38-cp38-win_amd64.whl", hash = "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d"}, + {file = "pyrsistent-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca"}, + {file = "pyrsistent-0.20.0-cp39-cp39-win32.whl", hash = "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f"}, + {file = "pyrsistent-0.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf"}, + {file = "pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b"}, + {file = "pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4"}, ] [[package]] name = "pyspellchecker" -version = "0.8.0" +version = "0.8.1" description = "Pure python spell checker based on work by Peter Norvig" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pyspellchecker-0.8.0-py3-none-any.whl", hash = "sha256:6a06129c38ff23ae2e250d4a3e7a7cebb990496a3c0fe60b28cc4e8c09312167"}, - {file = "pyspellchecker-0.8.0.tar.gz", hash = "sha256:0c13f129a18fb13dd028d1da9f3197f838cb6ec68b67a89092fe8406b2ec3170"}, + {file = "pyspellchecker-0.8.1-py3-none-any.whl", hash = "sha256:d91e9e1064793ae1ee8e71b06ca40eeb8e5923437c54291a8e041b447792b640"}, + {file = "pyspellchecker-0.8.1.tar.gz", hash = "sha256:3478ca8484d1c2db0c93d12b3c986cd17958c69f47b3ed7ef4d3f4201e591776"}, ] [[package]] @@ -3368,13 +3355,13 @@ test = ["coverage (>=4.5.2)", "flake8 (>=3.6.0,<=5.0.0)", "freezegun (>=0.3.11,< [[package]] name = "pytz" -version = "2023.3.post1" +version = "2023.4" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, + {file = "pytz-2023.4-py2.py3-none-any.whl", hash = "sha256:f90ef520d95e7c46951105338d918664ebfd6f1d995bd7d153127ce90efafa6a"}, + {file = "pytz-2023.4.tar.gz", hash = "sha256:31d4583c4ed539cd037956140d695e42c033a19e984bfce9964a3f7d59bc2b40"}, ] [[package]] @@ -4024,13 +4011,13 @@ files = [ [[package]] name = "tox" -version = "4.12.0" +version = "4.12.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.12.0-py3-none-any.whl", hash = "sha256:c94bf5852ba41f3d9f1e3470ccf3390e0b7bdc938095be3cd96dce25ab5062a0"}, - {file = "tox-4.12.0.tar.gz", hash = "sha256:76adc53a3baff7bde80d6ad7f63235735cfc5bc42e8cb6fccfbf62cb5ffd4d92"}, + {file = "tox-4.12.1-py3-none-any.whl", hash = "sha256:c07ea797880a44f3c4f200ad88ad92b446b83079d4ccef89585df64cc574375c"}, + {file = "tox-4.12.1.tar.gz", hash = "sha256:61aafbeff1bd8a5af84e54ef6e8402f53c6a6066d0782336171ddfbf5362122e"}, ] [package.dependencies] @@ -4140,13 +4127,13 @@ Flask = ">=2.0.0" [[package]] name = "types-jsonschema" -version = "4.20.0.20240105" +version = "4.21.0.20240118" description = "Typing stubs for jsonschema" optional = false python-versions = ">=3.8" files = [ - {file = "types-jsonschema-4.20.0.20240105.tar.gz", hash = "sha256:4a71af7e904498e7ad055149f6dc1eee04153b59a99ad7dd17aa3769c9bc5982"}, - {file = "types_jsonschema-4.20.0.20240105-py3-none-any.whl", hash = "sha256:26706cd70a273e59e718074c4e756608a25ba61327a7f9a4493ebd11941e5ad4"}, + {file = "types-jsonschema-4.21.0.20240118.tar.gz", hash = "sha256:31aae1b5adc0176c1155c2d4f58348b22d92ae64315e9cc83bd6902168839232"}, + {file = "types_jsonschema-4.21.0.20240118-py3-none-any.whl", hash = "sha256:77a4ac36b0be4f24274d5b9bf0b66208ee771c05f80e34c4641de7d63e8a872d"}, ] [package.dependencies] @@ -4165,35 +4152,35 @@ files = [ [[package]] name = "types-pillow" -version = "10.2.0.20240111" +version = "10.2.0.20240125" description = "Typing stubs for Pillow" optional = false python-versions = ">=3.8" files = [ - {file = "types-Pillow-10.2.0.20240111.tar.gz", hash = "sha256:e8d359bfdc5a149a3c90a7e153cb2d0750ddf7fc3508a20dfadabd8a9435e354"}, - {file = "types_Pillow-10.2.0.20240111-py3-none-any.whl", hash = "sha256:1f4243b30c143b56b0646626f052e4269123e550f9096cdfb5fbd999daee7dbb"}, + {file = "types-Pillow-10.2.0.20240125.tar.gz", hash = "sha256:c449b2c43b9fdbe0494a7b950e6b39a4e50516091213fec24ef3f33c1d017717"}, + {file = "types_Pillow-10.2.0.20240125-py3-none-any.whl", hash = "sha256:322dbae32b4b7918da5e8a47c50ac0f24b0aa72a804a23857620f2722b03c858"}, ] [[package]] name = "types-psycopg2" -version = "2.9.21.20240106" +version = "2.9.21.20240118" description = "Typing stubs for psycopg2" optional = false python-versions = ">=3.8" files = [ - {file = "types-psycopg2-2.9.21.20240106.tar.gz", hash = "sha256:0d0a350449714ba28448c4f10a0a3aec36e9e3efd1450730e227e17b704a4bea"}, - {file = "types_psycopg2-2.9.21.20240106-py3-none-any.whl", hash = "sha256:c20cf8236757f8ca4519068548f0c6c159158c9262cc7264c3f2f67f1f511b61"}, + {file = "types-psycopg2-2.9.21.20240118.tar.gz", hash = "sha256:e4a06316e7c9690255175c3ee5dffa5b47c5057f17181f5e34c6dcdb34066f35"}, + {file = "types_psycopg2-2.9.21.20240118-py3-none-any.whl", hash = "sha256:08c024f7da3a78c2c0404305f96c2b6067185d690cc4e9d14fc6ea595879ff8a"}, ] [[package]] name = "types-pyopenssl" -version = "23.3.0.20240106" +version = "24.0.0.20240130" description = "Typing stubs for pyOpenSSL" optional = false python-versions = ">=3.8" files = [ - {file = "types-pyOpenSSL-23.3.0.20240106.tar.gz", hash = "sha256:3d6f3462bec0c260caadf93fbb377225c126661b779c7d9ab99b6dad5ca10db9"}, - {file = "types_pyOpenSSL-23.3.0.20240106-py3-none-any.whl", hash = "sha256:47a7eedbd18b7bcad17efebf1c53416148f5a173918a6d75027e75e32fe039ae"}, + {file = "types-pyOpenSSL-24.0.0.20240130.tar.gz", hash = "sha256:c812e5c1c35249f75ef5935708b2a997d62abf9745be222e5f94b9595472ab25"}, + {file = "types_pyOpenSSL-24.0.0.20240130-py3-none-any.whl", hash = "sha256:24a255458b5b8a7fca8139cf56f2a8ad5a4f1a5f711b73a5bb9cb50dc688fab5"}, ] [package.dependencies] @@ -4212,13 +4199,13 @@ files = [ [[package]] name = "types-pytz" -version = "2023.3.1.1" +version = "2023.4.0.20240130" description = "Typing stubs for pytz" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "types-pytz-2023.3.1.1.tar.gz", hash = "sha256:cc23d0192cd49c8f6bba44ee0c81e4586a8f30204970fc0894d209a6b08dab9a"}, - {file = "types_pytz-2023.3.1.1-py3-none-any.whl", hash = "sha256:1999a123a3dc0e39a2ef6d19f3f8584211de9e6a77fe7a0259f04a524e90a5cf"}, + {file = "types-pytz-2023.4.0.20240130.tar.gz", hash = "sha256:33676a90bf04b19f92c33eec8581136bea2f35ddd12759e579a624a006fd387a"}, + {file = "types_pytz-2023.4.0.20240130-py3-none-any.whl", hash = "sha256:6ce76a9f8fd22bd39b01a59c35bfa2db39b60d11a2f77145e97b730de7e64fe0"}, ] [[package]] @@ -4505,4 +4492,4 @@ lxml = ">=3.8" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "bcd28f5925d0af63b549c2ccda1a61527913a206f8ddb9854b5818349d4ad8de" +content-hash = "f7b9c3968d4720fe231d398310ae34f0b919a61f255419014be233f704c31667" diff --git a/pyproject.toml b/pyproject.toml index 7954a673e..bd10e33bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,13 +68,12 @@ module = [ "api.admin.announcement_list_validator", "api.admin.config", "api.admin.controller.catalog_services", - "api.admin.controller.collection_self_tests", "api.admin.controller.collection_settings", "api.admin.controller.discovery_service_library_registrations", "api.admin.controller.discovery_services", "api.admin.controller.integration_settings", "api.admin.controller.library_settings", - "api.admin.controller.patron_auth_service_self_tests", + "api.admin.controller.metadata_services", "api.admin.controller.patron_auth_services", "api.admin.dashboard_stats", "api.admin.form_data", @@ -88,6 +87,7 @@ module = [ "api.integration.*", "api.lcp.hash", "api.marc", + "api.metadata.*", "api.odl", "api.odl2", "api.opds_for_distributors", @@ -206,7 +206,7 @@ html-sanitizer = "^2.1.0" isbnlib = "^3.10.14" itsdangerous = "^2.1.2" jwcrypto = "^1.4.2" -levenshtein = "^0.23" +levenshtein = "^0.24" lxml = "^4.9.3" money = "1.3.0" multipledispatch = "^1.0" @@ -215,7 +215,7 @@ nltk = "3.8.1" # nltk is a textblob dependency. openpyxl = "3.1.2" # Finland opensearch-dsl = "~1.0" opensearch-py = "~1.1" -palace-webpub-manifest-parser = "^3.1" +palace-webpub-manifest-parser = "^3.1.1" pillow = "^10.0" pycryptodome = "^3.18" pydantic = {version = "^1.10.9", extras = ["dotenv", "email"]} @@ -223,9 +223,9 @@ pyinstrument = "^4.6" PyJWT = "^2.8" PyLD = "2.0.3" pymarc = "5.1.1" -pyOpenSSL = "^23.1.0" +pyOpenSSL = "^24.0.0" pyparsing = "3.1.1" -pyspellchecker = "0.8.0" +pyspellchecker = "0.8.1" python = ">=3.10,<4" python-dateutil = "2.8.2" python3-saml = "^1.16" # python-saml is required for SAML authentication @@ -235,7 +235,7 @@ redmail = "^0.6.0" requests = "^2.29" sqlalchemy = {version = "^1.4", extras = ["mypy"]} textblob = "0.17.1" -types-pyopenssl = "^23.1.0.3" +types-pyopenssl = "^24.0.0.20240130" types-pyyaml = "^6.0.12.9" # We import typing_extensions, so we can use new annotation features. # - Self (Python 3.11) diff --git a/resources/OpenSans-Bold.ttf b/resources/OpenSans-Bold.ttf deleted file mode 100755 index fd79d43be..000000000 Binary files a/resources/OpenSans-Bold.ttf and /dev/null differ diff --git a/resources/OpenSans-Regular.ttf b/resources/OpenSans-Regular.ttf deleted file mode 100755 index db433349b..000000000 Binary files a/resources/OpenSans-Regular.ttf and /dev/null differ diff --git a/resources/images/FirstBookLoginButton280.png b/resources/images/FirstBookLoginButton280.png deleted file mode 100644 index 54d1d0591..000000000 Binary files a/resources/images/FirstBookLoginButton280.png and /dev/null differ diff --git a/scripts.py b/scripts.py index 1a99db4b7..77265cf92 100644 --- a/scripts.py +++ b/scripts.py @@ -23,15 +23,14 @@ from api.config import CannotLoadConfiguration, Configuration from api.lanes import create_default_lanes from api.local_analytics_exporter import LocalAnalyticsExporter -from api.novelist import NoveListAPI -from api.nyt import NYTBestSellerAPI +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI from api.opds_for_distributors import ( OPDSForDistributorsImporter, OPDSForDistributorsImportMonitor, OPDSForDistributorsReaperMonitor, ) from api.overdrive import OverdriveAPI -from core.external_search import ExternalSearchIndex from core.integration.goals import Goals from core.lane import Lane from core.marc import Annotator as MarcAnnotator @@ -96,7 +95,7 @@ def q(self): def run(self): q = self.q() - search_index_client = ExternalSearchIndex(self._db) + search_index_client = self.services.search.index() self.log.info("Attempting to repair metadata for %d works" % q.count()) success = 0 @@ -528,26 +527,12 @@ def initialize_database(self, connection: Connection) -> None: # Create a secret key if one doesn't already exist. ConfigurationSetting.sitewide_secret(session, Configuration.SECRET_KEY) - # Initialize the search client to create the "-current" alias. - try: - ExternalSearchIndex(session) - except CannotLoadConfiguration: - # Opensearch isn't configured, so do nothing. - pass - # Stamp the most recent migration as the current state of the DB alembic_conf = self._get_alembic_config(connection) command.stamp(alembic_conf, "head") - def initialize_search_indexes(self, _db: Session) -> bool: - try: - search = ExternalSearchIndex(_db) - except CannotLoadConfiguration as ex: - self.log.error( - "No search integration found yet, cannot initialize search indices." - ) - self.log.error(f"Error: {ex}") - return False + def initialize_search_indexes(self) -> bool: + search = self._container.search.index() return search.initialize_indices() def initialize(self, connection: Connection): @@ -569,8 +554,7 @@ def initialize(self, connection: Connection): self.initialize_database(connection) self.log.info("Initialization complete.") - with Session(connection) as session: - self.initialize_search_indexes(session) + self.initialize_search_indexes() def run(self) -> None: """ diff --git a/tests/api/admin/controller/test_admin_search_controller.py b/tests/api/admin/controller/test_admin_search_controller.py index 6d34f0d19..cad5d2cbc 100644 --- a/tests/api/admin/controller/test_admin_search_controller.py +++ b/tests/api/admin/controller/test_admin_search_controller.py @@ -1,19 +1,22 @@ +from unittest.mock import MagicMock + import pytest +from api.admin.controller.admin_search import AdminSearchController from core.model.classification import Subject from core.model.datasource import DataSource from core.model.licensing import LicensePool from core.model.work import Work -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture class AdminSearchFixture: - def __init__(self, admin_ctrl_fixture: AdminControllerFixture): - self.admin_ctrl_fixture = admin_ctrl_fixture - self.manager = admin_ctrl_fixture.manager - self.db = self.admin_ctrl_fixture.ctrl.db - - db = self.db + def __init__(self, db: DatabaseTransactionFixture): + self.db = db + mock_manager = MagicMock() + mock_manager._db = db.session + self.controller = AdminSearchController(mock_manager) # Setup works with subjects, languages, audiences etc... gutenberg = DataSource.lookup(db.session, DataSource.GUTENBERG) @@ -77,20 +80,23 @@ def __init__(self, admin_ctrl_fixture: AdminControllerFixture): @pytest.fixture(scope="function") def admin_search_fixture( - admin_ctrl_fixture: AdminControllerFixture, + db: DatabaseTransactionFixture, ) -> AdminSearchFixture: - return AdminSearchFixture(admin_ctrl_fixture) + return AdminSearchFixture(db) class TestAdminSearchController: - def test_search_field_values(self, admin_search_fixture: AdminSearchFixture): - with admin_search_fixture.admin_ctrl_fixture.request_context_with_library_and_admin( + def test_search_field_values( + self, + admin_search_fixture: AdminSearchFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context( "/", - library=admin_search_fixture.admin_ctrl_fixture.ctrl.db.default_library(), + library=db.default_library(), ): - response = ( - admin_search_fixture.manager.admin_search_controller.search_field_values() - ) + response = admin_search_fixture.controller.search_field_values() assert response["subjects"] == { "subject 1": 1, @@ -104,14 +110,19 @@ def test_search_field_values(self, admin_search_fixture: AdminSearchFixture): assert response["publishers"] == {"Publisher 1": 3, "Publisher 10": 10} assert response["distributors"] == {"Gutenberg": 13} - def test_different_license_types(self, admin_search_fixture: AdminSearchFixture): + def test_different_license_types( + self, + admin_search_fixture: AdminSearchFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): # Remove the cache - admin_search_fixture.manager.admin_search_controller.__class__._search_field_values_cached.ttls = ( # type: ignore + admin_search_fixture.controller.__class__._search_field_values_cached.ttls = ( # type: ignore 0 ) w = ( - admin_search_fixture.db.session.query(Work) + db.session.query(Work) .filter(Work.presentation_edition.has(title="work3")) .first() ) @@ -122,24 +133,20 @@ def test_different_license_types(self, admin_search_fixture: AdminSearchFixture) # A pool without licenses should not attribute to the count pool.licenses_owned = 0 - with admin_search_fixture.admin_ctrl_fixture.request_context_with_library_and_admin( + with flask_app_fixture.test_request_context( "/", - library=admin_search_fixture.admin_ctrl_fixture.ctrl.db.default_library(), + library=db.default_library(), ): - response = ( - admin_search_fixture.manager.admin_search_controller.search_field_values() - ) + response = admin_search_fixture.controller.search_field_values() assert "Horror" not in response["genres"] assert "Spanish" not in response["languages"] # An open access license should get counted even without owned licenses pool.open_access = True - with admin_search_fixture.admin_ctrl_fixture.request_context_with_library_and_admin( + with flask_app_fixture.test_request_context( "/", - library=admin_search_fixture.admin_ctrl_fixture.ctrl.db.default_library(), + library=db.default_library(), ): - response = ( - admin_search_fixture.manager.admin_search_controller.search_field_values() - ) + response = admin_search_fixture.controller.search_field_values() assert "Horror" in response["genres"] assert "Spanish" in response["languages"] diff --git a/tests/api/admin/controller/test_announcement_service.py b/tests/api/admin/controller/test_announcement_service.py index fb78b8053..108675b5b 100644 --- a/tests/api/admin/controller/test_announcement_service.py +++ b/tests/api/admin/controller/test_announcement_service.py @@ -1,23 +1,25 @@ import json import uuid -from werkzeug.datastructures import MultiDict +from werkzeug.datastructures import ImmutableMultiDict from api.admin.controller.announcement_service import AnnouncementSettings from core.model.announcements import Announcement, AnnouncementData from core.problem_details import INVALID_INPUT from core.util.problem_detail import ProblemDetail from tests.fixtures.announcements import AnnouncementFixture -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture class TestAnnouncementService: def test_get( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, announcement_fixture: AnnouncementFixture, + db: DatabaseTransactionFixture, ): - session = admin_ctrl_fixture.ctrl.db.session + session = db.session a1 = announcement_fixture.active_announcement(session) a2 = announcement_fixture.expired_announcement(session) a3 = announcement_fixture.forthcoming_announcement(session) @@ -34,8 +36,8 @@ def test_get( session.execute(Announcement.global_announcements()).scalars().all() ) - with admin_ctrl_fixture.request_context_with_admin("/", method="GET") as ctx: - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + with flask_app_fixture.test_request_context("/", method="GET"): + response = AnnouncementSettings(db.session).process_many() assert isinstance(response, dict) assert set(response.keys()) == {"settings", "announcements"} @@ -56,23 +58,24 @@ def test_get( def test_post( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, announcement_fixture: AnnouncementFixture, + db: DatabaseTransactionFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/", method="POST") as ctx: + with flask_app_fixture.test_request_context("/", method="POST") as ctx: data = AnnouncementData( id=uuid.uuid4(), start=announcement_fixture.yesterday, finish=announcement_fixture.tomorrow, content="This is a test announcement.", ) - ctx.request.form = MultiDict( + ctx.request.form = ImmutableMultiDict( [("announcements", json.dumps([data.as_dict()]))] ) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + response = AnnouncementSettings(db.session).process_many() assert response == {"success": True} - session = admin_ctrl_fixture.ctrl.db.session + session = db.session announcements = ( session.execute(Announcement.global_announcements()).scalars().all() ) @@ -84,15 +87,16 @@ def test_post( def test_post_edit( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, announcement_fixture: AnnouncementFixture, + db: DatabaseTransactionFixture, ): # Two existing announcements. - session = admin_ctrl_fixture.ctrl.db.session + session = db.session a1 = announcement_fixture.active_announcement(session) a2 = announcement_fixture.active_announcement(session) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST") as ctx: + with flask_app_fixture.test_request_context("/", method="POST") as ctx: # a1 is edited, a2 is deleted, a3 is added. a1_edited = a1.to_data() a1_edited.content = "This is an edited announcement." @@ -102,10 +106,10 @@ def test_post_edit( finish=announcement_fixture.tomorrow, content="This is new test announcement.", ) - ctx.request.form = MultiDict( + ctx.request.form = ImmutableMultiDict( [("announcements", json.dumps([a1_edited.as_dict(), a3.as_dict()]))] ) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + response = AnnouncementSettings(db.session).process_many() assert response == {"success": True} announcements = ( @@ -120,21 +124,21 @@ def test_post_edit( def test_post_errors( self, - admin_ctrl_fixture: AdminControllerFixture, - announcement_fixture: AnnouncementFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/", method="POST") as ctx: - ctx.request.form = None - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + with flask_app_fixture.test_request_context("/", method="POST") as ctx: + ctx.request.form = ImmutableMultiDict() + response = AnnouncementSettings(db.session).process_many() assert response == INVALID_INPUT - ctx.request.form = MultiDict([("somethingelse", json.dumps([]))]) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + ctx.request.form = ImmutableMultiDict([("somethingelse", json.dumps([]))]) + response = AnnouncementSettings(db.session).process_many() assert response == INVALID_INPUT - ctx.request.form = MultiDict( + ctx.request.form = ImmutableMultiDict( [("announcements", json.dumps([{"id": str(uuid.uuid4())}]))] ) - response = AnnouncementSettings(admin_ctrl_fixture.manager).process_many() + response = AnnouncementSettings(db.session).process_many() assert isinstance(response, ProblemDetail) assert "Missing required field: content" == response.detail diff --git a/tests/api/admin/controller/test_base.py b/tests/api/admin/controller/test_base.py index 635df2acf..249c117ea 100644 --- a/tests/api/admin/controller/test_base.py +++ b/tests/api/admin/controller/test_base.py @@ -1,59 +1,83 @@ import pytest +from api.admin.controller.base import AdminPermissionsControllerMixin from api.admin.exceptions import AdminNotAuthorized from core.model import AdminRole -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +@pytest.fixture() +def controller() -> AdminPermissionsControllerMixin: + return AdminPermissionsControllerMixin() class TestAdminPermissionsControllerMixin: - def test_require_system_admin(self, admin_ctrl_fixture: AdminControllerFixture): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + def test_require_system_admin( + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, + ): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_system_admin, + controller.require_system_admin, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - admin_ctrl_fixture.manager.admin_work_controller.require_system_admin() + with flask_app_fixture.test_request_context_system_admin("/admin"): + controller.require_system_admin() def test_require_sitewide_library_manager( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_sitewide_library_manager, + controller.require_sitewide_library_manager, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SITEWIDE_LIBRARY_MANAGER) - admin_ctrl_fixture.manager.admin_work_controller.require_sitewide_library_manager() + library_manager = flask_app_fixture.admin_user( + role=AdminRole.SITEWIDE_LIBRARY_MANAGER + ) + with flask_app_fixture.test_request_context("/admin", admin=library_manager): + controller.require_sitewide_library_manager() - def test_require_library_manager(self, admin_ctrl_fixture: AdminControllerFixture): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + def test_require_library_manager( + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_library_manager, - admin_ctrl_fixture.ctrl.db.default_library(), + controller.require_library_manager, + db.default_library(), ) - admin_ctrl_fixture.admin.add_role( - AdminRole.LIBRARY_MANAGER, admin_ctrl_fixture.ctrl.db.default_library() - ) - admin_ctrl_fixture.manager.admin_work_controller.require_library_manager( - admin_ctrl_fixture.ctrl.db.default_library() - ) + library_manager = flask_app_fixture.admin_user( + role=AdminRole.LIBRARY_MANAGER, library=db.default_library() + ) + with flask_app_fixture.test_request_context("/admin", admin=library_manager): + controller.require_library_manager(db.default_library()) - def test_require_librarian(self, admin_ctrl_fixture: AdminControllerFixture): - with admin_ctrl_fixture.request_context_with_admin("/admin"): + def test_require_librarian( + self, + controller: AdminPermissionsControllerMixin, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context("/admin"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_work_controller.require_librarian, - admin_ctrl_fixture.ctrl.db.default_library(), + controller.require_librarian, + db.default_library(), ) - admin_ctrl_fixture.admin.add_role( - AdminRole.LIBRARIAN, admin_ctrl_fixture.ctrl.db.default_library() - ) - admin_ctrl_fixture.manager.admin_work_controller.require_librarian( - admin_ctrl_fixture.ctrl.db.default_library() - ) + librarian = flask_app_fixture.admin_user( + role=AdminRole.LIBRARIAN, library=db.default_library() + ) + with flask_app_fixture.test_request_context("/admin", admin=librarian): + controller.require_librarian(db.default_library()) diff --git a/tests/api/admin/controller/test_catalog_services.py b/tests/api/admin/controller/test_catalog_services.py index fa8f5d76d..5c9e9e101 100644 --- a/tests/api/admin/controller/test_catalog_services.py +++ b/tests/api/admin/controller/test_catalog_services.py @@ -1,11 +1,13 @@ import json from contextlib import nullcontext +from unittest.mock import MagicMock import flask import pytest from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.catalog_services import CatalogServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, @@ -19,26 +21,31 @@ from api.integration.registry.catalog_services import CatalogServicesRegistry from core.integration.goals import Goals from core.marc import MARCExporter, MarcExporterLibrarySettings -from core.model import AdminRole, IntegrationConfiguration, get_one +from core.model import IntegrationConfiguration, get_one from core.util.problem_detail import ProblemDetail -from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> CatalogServicesController: + mock_manager = MagicMock() + mock_manager._db = db.session + return CatalogServicesController(mock_manager) class TestCatalogServicesController: def test_catalog_services_get_with_no_services( - self, admin_ctrl_fixture: AdminControllerFixture + self, flask_app_fixture: FlaskAppFixture, controller: CatalogServicesController ): - with admin_ctrl_fixture.request_context_with_admin("/"): + with flask_app_fixture.test_request_context("/"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services, + controller.process_catalog_services, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 200 data = response.json @@ -55,10 +62,11 @@ def test_catalog_services_get_with_no_services( assert "library_settings" in protocols[0] def test_catalog_services_get_with_marc_exporter( - self, admin_ctrl_fixture: AdminControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, ): - db = admin_ctrl_fixture.ctrl.db - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) library_settings = MarcExporterLibrarySettings( include_summary=True, include_genres=True, organization_code="US-MaBoDPL" ) @@ -77,10 +85,8 @@ def test_catalog_services_get_with_marc_exporter( library_settings_integration, library_settings ) - with admin_ctrl_fixture.request_context_with_admin("/"): - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 200 data = response.json @@ -93,10 +99,7 @@ def test_catalog_services_get_with_marc_exporter( assert integration.name == service.get("name") assert integration.protocol == service.get("protocol") [library] = service.get("libraries") - assert ( - admin_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") assert "US-MaBoDPL" == library.get("organization_code") assert library.get("include_summary") is True assert library.get("include_genres") is True @@ -156,18 +159,21 @@ def test_catalog_services_get_with_marc_exporter( ) def test_catalog_services_post_errors( self, - admin_ctrl_fixture: AdminControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, post_data: dict[str, str], expected: ProblemDetail | None, admin: bool, raises: type[Exception] | None, ): if admin: - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + make_request = flask_app_fixture.test_request_context_system_admin + else: + make_request = flask_app_fixture.test_request_context context_manager = pytest.raises(raises) if raises is not None else nullcontext() - db = admin_ctrl_fixture.ctrl.db service = db.integration_configuration( "fake protocol", Goals.CATALOG_GOAL, @@ -178,12 +184,10 @@ def test_catalog_services_post_errors( if post_data.get("id") == "": post_data["id"] = str(service.id) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with make_request("/", method="POST"): flask.request.form = ImmutableMultiDict(post_data) with context_manager: - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + response = controller.process_catalog_services() assert isinstance(response, ProblemDetail) assert isinstance(expected, ProblemDetail) assert response.uri == expected.uri @@ -191,14 +195,15 @@ def test_catalog_services_post_errors( assert response.title == expected.title def test_catalog_services_post_create( - self, admin_ctrl_fixture: AdminControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, ): - db = admin_ctrl_fixture.ctrl.db protocol = CatalogServicesRegistry().get_protocol(MARCExporter) assert protocol is not None - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "exporter name"), @@ -217,9 +222,7 @@ def test_catalog_services_post_create( ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 201 @@ -240,12 +243,13 @@ def test_catalog_services_post_create( assert settings.include_genres is True def test_catalog_services_post_edit( - self, admin_ctrl_fixture: AdminControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, ): - db = admin_ctrl_fixture.ctrl.db protocol = CatalogServicesRegistry().get_protocol(MARCExporter) assert protocol is not None - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) service = db.integration_configuration( protocol, @@ -253,7 +257,7 @@ def test_catalog_services_post_edit( name="name", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "exporter name"), @@ -273,9 +277,7 @@ def test_catalog_services_post_edit( ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_catalog_services() - ) + response = controller.process_catalog_services() assert isinstance(response, Response) assert response.status_code == 200 @@ -288,8 +290,12 @@ def test_catalog_services_post_edit( assert settings.include_summary is True assert settings.include_genres is False - def test_catalog_services_delete(self, admin_ctrl_fixture: AdminControllerFixture): - db = admin_ctrl_fixture.ctrl.db + def test_catalog_services_delete( + self, + flask_app_fixture: FlaskAppFixture, + controller: CatalogServicesController, + db: DatabaseTransactionFixture, + ): protocol = CatalogServicesRegistry().get_protocol(MARCExporter) assert protocol is not None @@ -299,17 +305,15 @@ def test_catalog_services_delete(self, admin_ctrl_fixture: AdminControllerFixtur name="name", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="DELETE"): + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_catalog_services_controller.process_delete, + controller.process_delete, service.id, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = admin_ctrl_fixture.manager.admin_catalog_services_controller.process_delete( - service.id - ) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + response = controller.process_delete(service.id) assert isinstance(response, Response) assert response.status_code == 200 diff --git a/tests/api/admin/controller/test_collection_self_tests.py b/tests/api/admin/controller/test_collection_self_tests.py deleted file mode 100644 index f48a3c12a..000000000 --- a/tests/api/admin/controller/test_collection_self_tests.py +++ /dev/null @@ -1,179 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from _pytest.monkeypatch import MonkeyPatch - -from api.admin.controller.collection_self_tests import CollectionSelfTestsController -from api.admin.problem_details import ( - FAILED_TO_RUN_SELF_TESTS, - MISSING_IDENTIFIER, - MISSING_SERVICE, - UNKNOWN_PROTOCOL, -) -from api.integration.registry.license_providers import LicenseProvidersRegistry -from api.selftest import HasCollectionSelfTests -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail, ProblemError -from tests.api.mockapi.axis import MockAxis360API -from tests.fixtures.database import DatabaseTransactionFixture - - -@pytest.fixture -def controller(db: DatabaseTransactionFixture) -> CollectionSelfTestsController: - return CollectionSelfTestsController(db.session) - - -class TestCollectionSelfTests: - def test_collection_self_tests_with_no_identifier( - self, controller: CollectionSelfTestsController - ): - response = controller.process_collection_self_tests(None) - assert isinstance(response, ProblemDetail) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_collection_self_tests_with_no_collection_found( - self, controller: CollectionSelfTestsController - ): - with pytest.raises(ProblemError) as excinfo: - controller.self_tests_process_get(-1) - assert excinfo.value.problem_detail == MISSING_SERVICE - - def test_collection_self_tests_with_unknown_protocol( - self, db: DatabaseTransactionFixture, controller: CollectionSelfTestsController - ): - collection = db.collection(protocol="test") - assert collection.integration_configuration.id is not None - with pytest.raises(ProblemError) as excinfo: - controller.self_tests_process_get(collection.integration_configuration.id) - assert excinfo.value.problem_detail == UNKNOWN_PROTOCOL - - def test_collection_self_tests_with_unsupported_protocol( - self, db: DatabaseTransactionFixture, controller: CollectionSelfTestsController - ): - registry = LicenseProvidersRegistry() - registry.register(object, canonical="mock_api") # type: ignore[arg-type] - collection = db.collection(protocol="mock_api") - controller = CollectionSelfTestsController(db.session, registry) - assert collection.integration_configuration.id is not None - result = controller.self_tests_process_get( - collection.integration_configuration.id - ) - - assert result.status_code == 200 - assert isinstance(result.json, dict) - assert result.json["self_test_results"]["self_test_results"] == { - "disabled": True, - "exception": "Self tests are not supported for this integration.", - } - - def test_collection_self_tests_test_get( - self, - db: DatabaseTransactionFixture, - controller: CollectionSelfTestsController, - monkeypatch: MonkeyPatch, - ): - collection = MockAxis360API.mock_collection( - db.session, - db.default_library(), - ) - - self_test_results = dict( - duration=0.9, - start="2018-08-08T16:04:05Z", - end="2018-08-08T16:05:05Z", - results=[], - ) - mock = MagicMock(return_value=self_test_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "load_self_test_results", mock - ) - - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's collection object. - assert collection.integration_configuration.id is not None - response = controller.self_tests_process_get( - collection.integration_configuration.id - ) - - data = response.json - assert isinstance(data, dict) - test_results = data.get("self_test_results") - assert isinstance(test_results, dict) - - assert test_results.get("id") == collection.integration_configuration.id - assert test_results.get("name") == collection.name - assert test_results.get("protocol") == collection.protocol - assert test_results.get("self_test_results") == self_test_results - assert mock.call_count == 1 - - def test_collection_self_tests_failed_post( - self, - db: DatabaseTransactionFixture, - controller: CollectionSelfTestsController, - monkeypatch: MonkeyPatch, - ): - collection = MockAxis360API.mock_collection( - db.session, - db.default_library(), - ) - - # This makes HasSelfTests.run_self_tests return no values - self_test_results = (None, None) - mock = MagicMock(return_value=self_test_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "run_self_tests", mock - ) - - # Failed to run self tests - assert collection.integration_configuration.id is not None - - with pytest.raises(ProblemError) as excinfo: - controller.self_tests_process_post(collection.integration_configuration.id) - - assert excinfo.value.problem_detail == FAILED_TO_RUN_SELF_TESTS - - def test_collection_self_tests_run_self_tests_unsupported_collection( - self, - db: DatabaseTransactionFixture, - ): - registry = LicenseProvidersRegistry() - registry.register(object, canonical="mock_api") # type: ignore[arg-type] - collection = db.collection(protocol="mock_api") - controller = CollectionSelfTestsController(db.session, registry) - response = controller.run_self_tests(collection.integration_configuration) - assert response is None - - def test_collection_self_tests_post( - self, - db: DatabaseTransactionFixture, - ): - mock = MagicMock() - - class MockApi(HasCollectionSelfTests): - def __new__(cls, *args, **kwargs): - nonlocal mock - return mock(*args, **kwargs) - - @property - def collection(self) -> None: - return None - - registry = LicenseProvidersRegistry() - registry.register(MockApi, canonical="Foo") # type: ignore[arg-type] - - collection = db.collection(protocol="Foo") - controller = CollectionSelfTestsController(db.session, registry) - - assert collection.integration_configuration.id is not None - response = controller.self_tests_process_post( - collection.integration_configuration.id - ) - - assert response.get_data(as_text=True) == "Successfully ran new self tests" - assert response.status_code == 200 - - mock.assert_called_once_with(db.session, collection) - mock()._run_self_tests.assert_called_once_with(db.session) - assert mock().store_self_test_results.call_count == 1 diff --git a/tests/api/admin/controller/test_collections.py b/tests/api/admin/controller/test_collections.py index 6685cbdb8..9ab737c46 100644 --- a/tests/api/admin/controller/test_collections.py +++ b/tests/api/admin/controller/test_collections.py @@ -1,16 +1,21 @@ import json +from unittest.mock import MagicMock, create_autospec import flask import pytest +from _pytest.monkeypatch import MonkeyPatch from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.collection_settings import CollectionSettingsController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, CANNOT_DELETE_COLLECTION_WITH_CHILDREN, + FAILED_TO_RUN_SELF_TESTS, INCOMPLETE_CONFIGURATION, INTEGRATION_NAME_ALREADY_IN_USE, + MISSING_IDENTIFIER, MISSING_PARENT, MISSING_SERVICE, MISSING_SERVICE_NAME, @@ -20,47 +25,82 @@ UNKNOWN_PROTOCOL, ) from api.integration.registry.license_providers import LicenseProvidersRegistry -from core.model import ( - Admin, - AdminRole, - Collection, - ExternalIntegration, - create, - get_one, -) -from core.util.problem_detail import ProblemDetail -from tests.fixtures.api_admin import AdminControllerFixture +from api.selftest import HasCollectionSelfTests +from core.model import AdminRole, Collection, ExternalIntegration, get_one +from core.selftest import HasSelfTests +from core.util.problem_detail import ProblemDetail, ProblemError +from tests.api.mockapi.axis import MockAxis360API from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> CollectionSettingsController: + mock_manager = MagicMock() + mock_manager._db = db.session + return CollectionSettingsController(mock_manager) class TestCollectionSettings: + def test_process_collections( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + ): + # Make sure when we call process_collections with a get request that + # we call process_get and when we call it with a post request that + # we call process_post. + + mock_process_get = create_autospec( + controller.process_get, return_value="get_response" + ) + controller.process_get = mock_process_get + + mock_process_post = create_autospec( + controller.process_post, return_value="post_response" + ) + controller.process_post = mock_process_post + + with flask_app_fixture.test_request_context("/"): + response = controller.process_collections() + assert response == "get_response" + + assert mock_process_get.call_count == 1 + assert mock_process_post.call_count == 0 + + mock_process_get.reset_mock() + mock_process_post.reset_mock() + + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_collections() + assert response == "post_response" + + assert mock_process_get.call_count == 0 + assert mock_process_post.call_count == 1 + def test_collections_get_with_no_collections( - self, admin_ctrl_fixture: AdminControllerFixture + self, controller: CollectionSettingsController, db: DatabaseTransactionFixture ) -> None: - db = admin_ctrl_fixture.ctrl.db # Delete any existing collections created by the test setup. db.session.delete(db.default_collection()) - with admin_ctrl_fixture.request_context_with_admin("/"): - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) - assert isinstance(response, Response) - assert response.status_code == 200 - data = response.json - assert isinstance(data, dict) - assert data.get("collections") == [] + response = controller.process_get() + assert isinstance(response, Response) + assert response.status_code == 200 + data = response.json + assert isinstance(data, dict) + assert data.get("collections") == [] - names = {p.get("name") for p in data.get("protocols", {})} - expected_names = {k for k, v in LicenseProvidersRegistry()} - assert names == expected_names + names = {p.get("name") for p in data.get("protocols", {})} + expected_names = {k for k, v in LicenseProvidersRegistry()} + assert names == expected_names def test_collections_get_collections_with_multiple_collections( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ) -> None: - session = admin_ctrl_fixture.ctrl.db.session - db = admin_ctrl_fixture.ctrl.db - [c1] = db.default_library().collections c2 = db.collection( @@ -87,76 +127,71 @@ def test_collections_get_collections_with_multiple_collections( l1_config = c3.integration_configuration.for_library(l1.id) assert l1_config is not None DatabaseTransactionFixture.set_settings(l1_config, ebook_loan_duration="14") - # Commit the config changes - session.commit() - - l1_librarian, ignore = create(session, Admin, email="admin@l1.org") - l1_librarian.add_role(AdminRole.LIBRARIAN, l1) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + admin = flask_app_fixture.admin_user() + l1_librarian = flask_app_fixture.admin_user( + email="admin@l1.org", role=AdminRole.LIBRARIAN, library=l1 + ) - with admin_ctrl_fixture.request_context_with_admin("/"): - controller = admin_ctrl_fixture.manager.admin_collection_settings_controller - response = controller.process_collections() - assert isinstance(response, Response) - assert response.status_code == 200 - data = response.json - assert isinstance(data, dict) - # The system admin can see all collections. - coll2, coll3, coll1 = sorted( - data.get("collections", []), key=lambda c: c.get("name", "") - ) - assert c1.integration_configuration.id == coll1.get("id") - assert c2.integration_configuration.id == coll2.get("id") - assert c3.integration_configuration.id == coll3.get("id") + with flask_app_fixture.test_request_context("/", admin=admin): + response1 = controller.process_get() + assert isinstance(response1, Response) + assert response1.status_code == 200 + data = response1.json + assert isinstance(data, dict) + # The system admin can see all collections. + coll2, coll3, coll1 = sorted( + data.get("collections", []), key=lambda c: c.get("name", "") + ) + assert c1.integration_configuration.id == coll1.get("id") + assert c2.integration_configuration.id == coll2.get("id") + assert c3.integration_configuration.id == coll3.get("id") - assert c1.name == coll1.get("name") - assert c2.name == coll2.get("name") - assert c3.name == coll3.get("name") + assert c1.name == coll1.get("name") + assert c2.name == coll2.get("name") + assert c3.name == coll3.get("name") - assert c1.protocol == coll1.get("protocol") - assert c2.protocol == coll2.get("protocol") - assert c3.protocol == coll3.get("protocol") + assert c1.protocol == coll1.get("protocol") + assert c2.protocol == coll2.get("protocol") + assert c3.protocol == coll3.get("protocol") - settings1 = coll1.get("settings", {}) - settings2 = coll2.get("settings", {}) - settings3 = coll3.get("settings", {}) + settings1 = coll1.get("settings", {}) + settings2 = coll2.get("settings", {}) + settings3 = coll3.get("settings", {}) - assert ( - settings1.get("external_account_id") == "http://opds.example.com/feed" - ) - assert settings2.get("external_account_id") == "1234" - assert settings3.get("external_account_id") == "5678" + assert settings1.get("external_account_id") == "http://opds.example.com/feed" + assert settings2.get("external_account_id") == "1234" + assert settings3.get("external_account_id") == "5678" - assert c2.integration_configuration.settings_dict[ - "overdrive_client_secret" - ] == settings2.get("overdrive_client_secret") + assert c2.integration_configuration.settings_dict[ + "overdrive_client_secret" + ] == settings2.get("overdrive_client_secret") - assert c2.integration_configuration.id == coll3.get("parent_id") + assert c2.integration_configuration.id == coll3.get("parent_id") - coll3_libraries = coll3.get("libraries") - assert 2 == len(coll3_libraries) - coll3_l1, coll3_default = sorted( - coll3_libraries, key=lambda x: x.get("short_name") - ) - assert "L1" == coll3_l1.get("short_name") - assert "14" == coll3_l1.get("ebook_loan_duration") - assert db.default_library().short_name == coll3_default.get("short_name") + coll3_libraries = coll3.get("libraries") + assert 2 == len(coll3_libraries) + coll3_l1, coll3_default = sorted( + coll3_libraries, key=lambda x: x.get("short_name") + ) + assert "L1" == coll3_l1.get("short_name") + assert "14" == coll3_l1.get("ebook_loan_duration") + assert db.default_library().short_name == coll3_default.get("short_name") - with admin_ctrl_fixture.request_context_with_admin("/", admin=l1_librarian): + with flask_app_fixture.test_request_context("/", admin=l1_librarian): # A librarian only sees collections associated with their library. - response = controller.process_collections() - assert isinstance(response, Response) - assert response.status_code == 200 - data = response.json - assert isinstance(data, dict) - [coll3] = data.get("collections", []) - assert c3.integration_configuration.id == coll3.get("id") - - coll3_libraries = coll3.get("libraries") - assert 1 == len(coll3_libraries) - assert "L1" == coll3_libraries[0].get("short_name") - assert "14" == coll3_libraries[0].get("ebook_loan_duration") + response2 = controller.process_collections() + assert isinstance(response2, Response) + assert response2.status_code == 200 + data = response2.json + assert isinstance(data, dict) + [coll3] = data.get("collections", []) + assert c3.integration_configuration.id == coll3.get("id") + + coll3_libraries = coll3.get("libraries") + assert 1 == len(coll3_libraries) + assert "L1" == coll3_libraries[0].get("short_name") + assert "14" == coll3_libraries[0].get("ebook_loan_duration") @pytest.mark.parametrize( "post_data,expected,detailed", @@ -269,25 +304,23 @@ def test_collections_get_collections_with_multiple_collections( ) def test_collections_post_errors( self, - admin_ctrl_fixture: AdminControllerFixture, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, post_data: dict[str, str], expected: ProblemDetail, detailed: bool, ): - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - - collection = admin_ctrl_fixture.ctrl.db.collection( + collection = db.collection( name="Collection 1", protocol=ExternalIntegration.OVERDRIVE ) if "id" in post_data and post_data["id"] == "": post_data["id"] = str(collection.integration_configuration.id) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict(post_data) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() if detailed: assert isinstance(response, ProblemDetail) @@ -297,9 +330,11 @@ def test_collections_post_errors( assert response == expected def test_collections_post_errors_no_permissions( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, ): - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Collection 1"), @@ -308,12 +343,15 @@ def test_collections_post_errors_no_permissions( ) pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections, + controller.process_collections, ) - def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixture): - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - db = admin_ctrl_fixture.ctrl.db + def test_collections_post_create( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): l1 = db.library( name="Library 1", short_name="L1", @@ -327,7 +365,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur short_name="L3", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "New Collection"), @@ -347,9 +385,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur ("overdrive_website_id", "1234"), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert isinstance(response, Response) assert response.status_code == 201 @@ -397,7 +433,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur assert "l2_ils" == l2_settings.settings_dict["ils_name"] # This collection will be a child of the first collection. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Child Collection"), @@ -410,9 +446,7 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur ("external_account_id", "child-acctid"), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert isinstance(response, Response) assert response.status_code == 201 @@ -438,10 +472,13 @@ def test_collections_post_create(self, admin_ctrl_fixture: AdminControllerFixtur assert l3_settings is not None assert "l3_ils" == l3_settings.settings_dict["ils_name"] - def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture): + def test_collections_post_edit( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): # The collection exists. - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - db = admin_ctrl_fixture.ctrl.db collection = db.collection( name="Collection 1", protocol=ExternalIntegration.OVERDRIVE ) @@ -451,7 +488,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) short_name="L1", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -468,9 +505,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -487,7 +522,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ) # A library now has access to the collection. - assert [collection] == l1.collections + assert collection.libraries == [l1] # Additional settings were set on the collection. assert "1234" == collection.integration_configuration.settings_dict.get( @@ -498,7 +533,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) assert l1_settings is not None assert "the_ils" == l1_settings.settings_dict.get("ils_name") - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -511,9 +546,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ("libraries", json.dumps([])), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -526,7 +559,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) assert ExternalIntegration.OVERDRIVE == collection.protocol # But the library has been removed. - assert [] == l1.collections + assert collection.libraries == [] # All ConfigurationSettings for that library and collection # have been deleted. @@ -534,7 +567,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) parent = db.collection(name="Parent", protocol=ExternalIntegration.OVERDRIVE) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -545,9 +578,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ("libraries", json.dumps([])), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -560,7 +591,7 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) collection2 = db.collection( name="Collection 2", protocol=ExternalIntegration.ODL ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection2.integration_configuration.id)), @@ -585,13 +616,10 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) - admin_ctrl_fixture.ctrl.db.session.refresh(collection2) assert len(collection2.integration_configuration.library_configurations) == 1 # The library configuration value was correctly coerced to int assert ( @@ -602,11 +630,12 @@ def test_collections_post_edit(self, admin_ctrl_fixture: AdminControllerFixture) ) def test_collections_post_edit_library_specific_configuration( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ): # The collection exists. - db = admin_ctrl_fixture.ctrl.db - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) collection = db.collection( name="Collection 1", protocol=ExternalIntegration.AXIS_360 ) @@ -616,7 +645,7 @@ def test_collections_post_edit_library_specific_configuration( short_name="L1", ) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -632,9 +661,7 @@ def test_collections_post_edit_library_specific_configuration( ), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 # Additional settings were set on the collection+library. @@ -644,7 +671,7 @@ def test_collections_post_edit_library_specific_configuration( assert "14" == l1_settings.settings_dict.get("ebook_loan_duration") # Remove the connection between collection and library. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(collection.integration_configuration.id)), @@ -657,9 +684,7 @@ def test_collections_post_edit_library_specific_configuration( ("libraries", json.dumps([])), ] ) - response = ( - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_collections() - ) + response = controller.process_collections() assert response.status_code == 200 assert isinstance(response, Response) @@ -671,21 +696,25 @@ def test_collections_post_edit_library_specific_configuration( assert collection.integration_configuration.for_library(l1.id) is None assert [] == collection.libraries - def test_collection_delete(self, admin_ctrl_fixture: AdminControllerFixture): - db = admin_ctrl_fixture.ctrl.db + def test_collection_delete( + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): collection = db.collection() assert collection.marked_for_deletion is False - with admin_ctrl_fixture.request_context_with_admin("/", method="DELETE"): + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - admin_ctrl_fixture.manager.admin_collection_settings_controller.process_delete, + controller.process_delete, collection.integration_configuration.id, ) - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): assert collection.integration_configuration.id is not None - response = admin_ctrl_fixture.manager.admin_collection_settings_controller.process_delete( + response = controller.process_delete( collection.integration_configuration.id ) assert response.status_code == 200 @@ -699,17 +728,176 @@ def test_collection_delete(self, admin_ctrl_fixture: AdminControllerFixture): assert fetched_collection.marked_for_deletion is True def test_collection_delete_cant_delete_parent( - self, admin_ctrl_fixture: AdminControllerFixture + self, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, ): - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - db = admin_ctrl_fixture.ctrl.db parent = db.collection(protocol=ExternalIntegration.OVERDRIVE) child = db.collection(protocol=ExternalIntegration.OVERDRIVE) child.parent = parent - with admin_ctrl_fixture.request_context_with_admin("/", method="DELETE"): + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): assert parent.integration_configuration.id is not None - response = admin_ctrl_fixture.manager.admin_collection_settings_controller.process_delete( - parent.integration_configuration.id - ) + response = controller.process_delete(parent.integration_configuration.id) assert response == CANNOT_DELETE_COLLECTION_WITH_CHILDREN + + def test_collection_self_tests_with_no_identifier( + self, controller: CollectionSettingsController + ): + response = controller.process_collection_self_tests(None) + assert isinstance(response, ProblemDetail) + assert response.title == MISSING_IDENTIFIER.title + assert response.detail == MISSING_IDENTIFIER.detail + assert response.status_code == 400 + + def test_collection_self_tests_with_no_collection_found( + self, controller: CollectionSettingsController + ): + with pytest.raises(ProblemError) as excinfo: + controller.self_tests_process_get(-1) + assert excinfo.value.problem_detail == MISSING_SERVICE + + def test_collection_self_tests_with_unknown_protocol( + self, db: DatabaseTransactionFixture, controller: CollectionSettingsController + ): + collection = db.collection(protocol="test") + assert collection.integration_configuration.id is not None + with pytest.raises(ProblemError) as excinfo: + controller.self_tests_process_get(collection.integration_configuration.id) + assert excinfo.value.problem_detail == UNKNOWN_PROTOCOL + + def test_collection_self_tests_with_unsupported_protocol( + self, db: DatabaseTransactionFixture, flask_app_fixture: FlaskAppFixture + ): + registry = LicenseProvidersRegistry() + registry.register(object, canonical="mock_api") # type: ignore[arg-type] + collection = db.collection(protocol="mock_api") + manager = MagicMock() + manager._db = db.session + controller = CollectionSettingsController(manager, registry) + assert collection.integration_configuration.id is not None + + with flask_app_fixture.test_request_context_system_admin("/"): + result = controller.self_tests_process_get( + collection.integration_configuration.id + ) + + assert result.status_code == 200 + assert isinstance(result.json, dict) + assert result.json["self_test_results"]["self_test_results"] == { + "disabled": True, + "exception": "Self tests are not supported for this integration.", + } + + def test_collection_self_tests_test_get( + self, + db: DatabaseTransactionFixture, + controller: CollectionSettingsController, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + ): + collection = MockAxis360API.mock_collection( + db.session, + db.default_library(), + ) + + self_test_results = dict( + duration=0.9, + start="2018-08-08T16:04:05Z", + end="2018-08-08T16:05:05Z", + results=[], + ) + mock = MagicMock(return_value=self_test_results) + monkeypatch.setattr(HasSelfTests, "load_self_test_results", mock) + + # Make sure that HasSelfTest.prior_test_results() was called and that + # it is in the response's collection object. + assert collection.integration_configuration.id is not None + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.self_tests_process_get( + collection.integration_configuration.id + ) + + data = response.json + assert isinstance(data, dict) + test_results = data.get("self_test_results") + assert isinstance(test_results, dict) + + assert test_results.get("id") == collection.integration_configuration.id + assert test_results.get("name") == collection.name + assert test_results.get("protocol") == collection.protocol + assert test_results.get("self_test_results") == self_test_results + assert mock.call_count == 1 + + def test_collection_self_tests_failed_post( + self, + db: DatabaseTransactionFixture, + controller: CollectionSettingsController, + monkeypatch: MonkeyPatch, + ): + collection = MockAxis360API.mock_collection( + db.session, + db.default_library(), + ) + + # This makes HasSelfTests.run_self_tests return no values + self_test_results = (None, None) + mock = MagicMock(return_value=self_test_results) + monkeypatch.setattr(HasSelfTests, "run_self_tests", mock) + + # Failed to run self tests + assert collection.integration_configuration.id is not None + + with pytest.raises(ProblemError) as excinfo: + controller.self_tests_process_post(collection.integration_configuration.id) + + assert excinfo.value.problem_detail == FAILED_TO_RUN_SELF_TESTS + + def test_collection_self_tests_run_self_tests_unsupported_collection( + self, + db: DatabaseTransactionFixture, + ): + registry = LicenseProvidersRegistry() + registry.register(object, canonical="mock_api") # type: ignore[arg-type] + collection = db.collection(protocol="mock_api") + manager = MagicMock() + manager._db = db.session + controller = CollectionSettingsController(manager, registry) + response = controller.run_self_tests(collection.integration_configuration) + assert response is None + + def test_collection_self_tests_post( + self, + db: DatabaseTransactionFixture, + ): + mock = MagicMock() + + class MockApi(HasCollectionSelfTests): + def __new__(cls, *args, **kwargs): + nonlocal mock + return mock(*args, **kwargs) + + @property + def collection(self) -> None: + return None + + registry = LicenseProvidersRegistry() + registry.register(MockApi, canonical="Foo") # type: ignore[arg-type] + + collection = db.collection(protocol="Foo") + manager = MagicMock() + manager._db = db.session + controller = CollectionSettingsController(manager, registry) + + assert collection.integration_configuration.id is not None + response = controller.self_tests_process_post( + collection.integration_configuration.id + ) + + assert response.get_data(as_text=True) == "Successfully ran new self tests" + assert response.status_code == 200 + + mock.assert_called_once_with(db.session, collection) + mock()._run_self_tests.assert_called_once_with(db.session) + assert mock().store_self_test_results.call_count == 1 diff --git a/tests/api/admin/controller/test_custom_lists.py b/tests/api/admin/controller/test_custom_lists.py index eff19c8ce..2928a45c9 100644 --- a/tests/api/admin/controller/test_custom_lists.py +++ b/tests/api/admin/controller/test_custom_lists.py @@ -1,4 +1,5 @@ import json +import logging from unittest import mock import feedparser @@ -449,7 +450,10 @@ def test_custom_list_get(self, admin_librarian_fixture: AdminLibrarianFixture): list.add_entry(work1) list.add_entry(work2) - with admin_librarian_fixture.request_context_with_library_and_admin("/"): + with ( + admin_librarian_fixture.request_context_with_library_and_admin("/"), + admin_librarian_fixture.ctrl.wired_container(), + ): assert isinstance(list.id, int) response = admin_librarian_fixture.manager.admin_custom_lists_controller.custom_list( list.id @@ -966,8 +970,9 @@ def test_share_locally_success( assert response["failures"] == 1 # The default library def test_share_locally_with_invalid_entries( - self, admin_librarian_fixture: AdminLibrarianFixture + self, admin_librarian_fixture: AdminLibrarianFixture, caplog ): + caplog.set_level(logging.INFO, "core.query.customlist.CustomListQueries") s = self._setup_share_locally(admin_librarian_fixture) s.collection1.libraries.append(s.shared_with) @@ -983,6 +988,52 @@ def test_share_locally_with_invalid_entries( assert response["failures"] == 2 assert response["successes"] == 0 + assert self.message_found_n_times( + caplog, "This list contains 1 entry without an associated work", 0 + ) + assert self.message_found_n_times( + caplog, "Unable to share customlist: No license for work", 1 + ) + + def test_share_locally_with_entry_with_missing_work( + self, admin_librarian_fixture: AdminLibrarianFixture, caplog + ): + caplog.set_level(logging.INFO, "core.query.customlist.CustomListQueries") + s = self._setup_share_locally(admin_librarian_fixture) + s.collection1.libraries.append(s.shared_with) + + w = admin_librarian_fixture.ctrl.db.work(collection=s.collection1) + entry, ignore = s.list.add_entry(w) + + entry.work = None + entry.work_id = None + + assert entry.edition is not None + + response = self._share_locally( + s.list, s.primary_library, admin_librarian_fixture + ) + + assert response["failures"] == 1 # The default library + assert response["successes"] == 1 + assert self.message_found_n_times( + caplog, "This list contains 1 entry without an associated work", 1 + ) + + def message_found_n_times(self, caplog, message: str, occurrences: int = 1): + return ( + len( + [ + x + for x in caplog.messages + if x.__contains__( + message, + ) + ] + ) + == occurrences + ) + def test_share_locally_get(self, admin_librarian_fixture: AdminLibrarianFixture): """Does the GET method fetch shared lists""" s = self._setup_share_locally(admin_librarian_fixture) diff --git a/tests/api/admin/controller/test_discovery_services.py b/tests/api/admin/controller/test_discovery_services.py index 5c5ed4d07..d533817dc 100644 --- a/tests/api/admin/controller/test_discovery_services.py +++ b/tests/api/admin/controller/test_discovery_services.py @@ -1,12 +1,14 @@ from __future__ import annotations from typing import TYPE_CHECKING +from unittest.mock import MagicMock import flask import pytest from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.discovery_services import DiscoveryServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( INCOMPLETE_CONFIGURATION, @@ -19,12 +21,22 @@ from api.discovery.opds_registration import OpdsRegistrationService from api.integration.registry.discovery import DiscoveryRegistry from core.integration.goals import Goals -from core.model import AdminRole, ExternalIntegration, IntegrationConfiguration, get_one +from core.model import ExternalIntegration, IntegrationConfiguration, get_one from core.util.problem_detail import ProblemDetail +from tests.fixtures.flask import FlaskAppFixture if TYPE_CHECKING: - from tests.fixtures.api_admin import SettingsControllerFixture - from tests.fixtures.database import IntegrationConfigurationFixture + from tests.fixtures.database import ( + DatabaseTransactionFixture, + IntegrationConfigurationFixture, + ) + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> DiscoveryServicesController: + mock_manager = MagicMock() + mock_manager._db = db.session + return DiscoveryServicesController(mock_manager) class TestDiscoveryServices: @@ -39,12 +51,12 @@ def protocol(self): return registry.get_protocol(OpdsRegistrationService) def test_discovery_services_get_with_no_services_creates_default( - self, settings_ctrl_fixture: SettingsControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_discovery_services() assert response.status_code == 200 assert isinstance(response, Response) json = response.get_json() @@ -60,25 +72,26 @@ def test_discovery_services_get_with_no_services_creates_default( "name" ) + with flask_app_fixture.test_request_context("/"): # Only system admins can see the discovery services. - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services, + controller.process_discovery_services, ) def test_discovery_services_get_with_one_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, ): discovery_service = create_integration_configuration.discovery_service( - url=settings_ctrl_fixture.ctrl.db.fresh_str() + url=db.fresh_str() ) - controller = settings_ctrl_fixture.manager.admin_discovery_services_controller + controller = controller - with settings_ctrl_fixture.request_context_with_admin("/"): + with flask_app_fixture.test_request_context_system_admin("/"): response = controller.process_discovery_services() assert isinstance(response, Response) [service] = response.get_json().get("discovery_services") @@ -91,11 +104,13 @@ def test_discovery_services_get_with_one_service( def test_discovery_services_post_errors( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, ): - controller = settings_ctrl_fixture.manager.admin_discovery_services_controller - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + controller = controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -105,7 +120,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == UNKNOWN_PROTOCOL - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -114,7 +129,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == NO_PROTOCOL_FOR_NEW_SERVICE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -125,11 +140,11 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == MISSING_SERVICE - integration_url = settings_ctrl_fixture.ctrl.db.fresh_url() + integration_url = db.fresh_url() existing_integration = create_integration_configuration.discovery_service( url=integration_url ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): assert isinstance(existing_integration.name, str) flask.request.form = ImmutableMultiDict( [ @@ -141,7 +156,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == INTEGRATION_NAME_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): assert isinstance(existing_integration.protocol, str) flask.request.form = ImmutableMultiDict( [ @@ -153,7 +168,7 @@ def test_discovery_services_post_errors( response = controller.process_discovery_services() assert response == INTEGRATION_URL_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("id", str(existing_integration.id)), @@ -164,8 +179,7 @@ def test_discovery_services_post_errors( assert isinstance(response, ProblemDetail) assert response.uri == INCOMPLETE_CONFIGURATION.uri - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("protocol", self.protocol), @@ -175,9 +189,12 @@ def test_discovery_services_post_errors( pytest.raises(AdminNotAuthorized, controller.process_discovery_services) def test_discovery_services_post_create( - self, settings_ctrl_fixture: SettingsControllerFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, ): - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -185,13 +202,11 @@ def test_discovery_services_post_create( (ExternalIntegration.URL, "http://registry.url"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert response.status_code == 201 service = get_one( - settings_ctrl_fixture.ctrl.db.session, + db.session, IntegrationConfiguration, goal=Goals.DISCOVERY_GOAL, ) @@ -205,14 +220,15 @@ def test_discovery_services_post_create( def test_discovery_services_post_edit( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, create_integration_configuration: IntegrationConfigurationFixture, ): discovery_service = create_integration_configuration.discovery_service( url="registry url" ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Name"), @@ -221,9 +237,7 @@ def test_discovery_services_post_edit( (ExternalIntegration.URL, "http://new_registry_url.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert response.status_code == 200 assert isinstance(response, Response) @@ -236,7 +250,8 @@ def test_discovery_services_post_edit( def test_check_name_unique( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, create_integration_configuration: IntegrationConfigurationFixture, ): existing_service = create_integration_configuration.discovery_service() @@ -244,7 +259,7 @@ def test_check_name_unique( # Try to change new service so that it has the same name as existing service # -- this is not allowed. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", str(existing_service.name)), @@ -253,13 +268,11 @@ def test_check_name_unique( ("url", "http://test.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert response == INTEGRATION_NAME_ALREADY_IN_USE # Try to edit existing service without changing its name -- this is fine. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", str(existing_service.name)), @@ -268,14 +281,12 @@ def test_check_name_unique( ("url", "http://test.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert isinstance(response, Response) assert response.status_code == 200 # Changing the existing service's name is also fine. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "New name"), @@ -284,38 +295,37 @@ def test_check_name_unique( ("url", "http://test.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_discovery_services() - ) + response = controller.process_discovery_services() assert isinstance(response, Response) assert response.status_code == 200 def test_discovery_service_delete( self, - settings_ctrl_fixture: SettingsControllerFixture, + flask_app_fixture: FlaskAppFixture, + controller: DiscoveryServicesController, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, ): discovery_service = create_integration_configuration.discovery_service( url="registry url" ) - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_discovery_services_controller.process_delete, + controller.process_delete, discovery_service.id, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_discovery_services_controller.process_delete( + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + response = controller.process_delete( discovery_service.id # type: ignore[arg-type] ) assert response.status_code == 200 service = get_one( - settings_ctrl_fixture.ctrl.db.session, + db.session, IntegrationConfiguration, id=discovery_service.id, ) - assert None == service + assert service is None diff --git a/tests/api/admin/controller/test_feed.py b/tests/api/admin/controller/test_feed.py index 42f3d3f8b..29415667f 100644 --- a/tests/api/admin/controller/test_feed.py +++ b/tests/api/admin/controller/test_feed.py @@ -15,7 +15,10 @@ def test_suppressed(self, admin_librarian_fixture): unsuppressed_work = admin_librarian_fixture.ctrl.db.work() - with admin_librarian_fixture.request_context_with_library_and_admin("/"): + with ( + admin_librarian_fixture.request_context_with_library_and_admin("/"), + admin_librarian_fixture.ctrl.wired_container(), + ): response = ( admin_librarian_fixture.manager.admin_feed_controller.suppressed() ) diff --git a/tests/api/admin/controller/test_individual_admins.py b/tests/api/admin/controller/test_individual_admins.py index 9e90f4a26..c10a7a292 100644 --- a/tests/api/admin/controller/test_individual_admins.py +++ b/tests/api/admin/controller/test_individual_admins.py @@ -2,22 +2,36 @@ import flask import pytest -from werkzeug.datastructures import MultiDict +from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.individual_admin_settings import ( + IndividualAdminSettingsController, +) from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( ADMIN_AUTH_NOT_CONFIGURED, INCOMPLETE_CONFIGURATION, + INVALID_EMAIL, UNKNOWN_ROLE, ) from api.problem_details import LIBRARY_NOT_FOUND from core.model import Admin, AdminRole, create, get_one +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture -class TestIndividualAdmins: - def test_individual_admins_get(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> IndividualAdminSettingsController: + return IndividualAdminSettingsController(db.session) + +class TestIndividualAdmins: + def test_individual_admins_get( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): for admin in db.session.query(Admin): db.session.delete(admin) @@ -42,38 +56,36 @@ def test_individual_admins_get(self, settings_ctrl_fixture): admin6, ignore = create(db.session, Admin, email="admin6@l2.org") admin6.add_role(AdminRole.SITEWIDE_LIBRARY_MANAGER) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin1): + with flask_app_fixture.test_request_context("/", admin=admin1): # A system admin can see all other admins' roles. - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) - admins = response.get("individualAdmins") + response = controller.process_get() + admins = response.get("individualAdmins", []) expected = { "admin1@nypl.org": [{"role": AdminRole.SYSTEM_ADMIN}], "admin2@nypl.org": [ { "role": AdminRole.LIBRARY_MANAGER, - "library": db.default_library().short_name, + "library": str(db.default_library().short_name), }, {"role": AdminRole.SITEWIDE_LIBRARIAN}, ], "admin3@nypl.org": [ { "role": AdminRole.LIBRARIAN, - "library": db.default_library().short_name, + "library": str(db.default_library().short_name), } ], "admin4@l2.org": [ { "role": AdminRole.LIBRARY_MANAGER, - "library": library2.short_name, + "library": str(library2.short_name), } ], "admin5@l2.org": [ { "role": AdminRole.LIBRARIAN, - "library": library2.short_name, + "library": str(library2.short_name), } ], "admin6@l2.org": [ @@ -90,177 +102,177 @@ def test_individual_admins_get(self, settings_ctrl_fixture): expected[admin["email"]], key=lambda x: x["role"] ) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin2): + with flask_app_fixture.test_request_context("/", admin=admin2): # A sitewide librarian or library manager can also see all admins' roles. - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + response = controller.process_get() admins = response.get("individualAdmins") + expected_admins: list[dict[str, str | list[dict[str, str]]]] = [ + { + "email": "admin2@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(db.default_library().short_name), + }, + {"role": AdminRole.SITEWIDE_LIBRARIAN}, + ], + }, + { + "email": "admin3@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(db.default_library().short_name), + } + ], + }, + { + "email": "admin6@l2.org", + "roles": [ + { + "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, + } + ], + }, + ] assert sorted( - [ - { - "email": "admin2@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": db.default_library().short_name, - }, - {"role": AdminRole.SITEWIDE_LIBRARIAN}, - ], - }, - { - "email": "admin3@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": db.default_library().short_name, - } - ], - }, - { - "email": "admin6@l2.org", - "roles": [ - { - "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, - } - ], - }, - ], + expected_admins, key=lambda x: x["email"], ) == sorted(admins, key=lambda x: x["email"]) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin3): + with flask_app_fixture.test_request_context("/", admin=admin3): # A librarian cannot view this API anymore pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get, + controller.process_get, ) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin4): - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context("/", admin=admin4): + response = controller.process_get() admins = response.get("individualAdmins") + expected_admins = [ + { + "email": "admin2@nypl.org", + "roles": [{"role": AdminRole.SITEWIDE_LIBRARIAN}], + }, + { + "email": "admin4@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin5@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin6@l2.org", + "roles": [ + { + "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, + } + ], + }, + ] assert sorted( - [ - { - "email": "admin2@nypl.org", - "roles": [{"role": AdminRole.SITEWIDE_LIBRARIAN}], - }, - { - "email": "admin4@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": library2.short_name, - } - ], - }, - { - "email": "admin5@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": library2.short_name, - } - ], - }, - { - "email": "admin6@l2.org", - "roles": [ - { - "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, - } - ], - }, - ], + expected_admins, key=lambda x: x["email"], ) == sorted(admins, key=lambda x: x["email"]) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin5): + with flask_app_fixture.test_request_context("/", admin=admin5): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get, + controller.process_get, ) - with settings_ctrl_fixture.request_context_with_admin("/", admin=admin6): - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context("/", admin=admin6): + response = controller.process_get() admins = response.get("individualAdmins") + expected_admins = [ + { + "email": "admin2@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(db.default_library().short_name), + }, + {"role": AdminRole.SITEWIDE_LIBRARIAN}, + ], + }, + { + "email": "admin3@nypl.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(db.default_library().short_name), + } + ], + }, + { + "email": "admin4@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARY_MANAGER, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin5@l2.org", + "roles": [ + { + "role": AdminRole.LIBRARIAN, + "library": str(library2.short_name), + } + ], + }, + { + "email": "admin6@l2.org", + "roles": [ + { + "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, + } + ], + }, + ] assert sorted( - [ - { - "email": "admin2@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": db.default_library().short_name, - }, - {"role": AdminRole.SITEWIDE_LIBRARIAN}, - ], - }, - { - "email": "admin3@nypl.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": db.default_library().short_name, - } - ], - }, - { - "email": "admin4@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARY_MANAGER, - "library": library2.short_name, - } - ], - }, - { - "email": "admin5@l2.org", - "roles": [ - { - "role": AdminRole.LIBRARIAN, - "library": library2.short_name, - } - ], - }, - { - "email": "admin6@l2.org", - "roles": [ - { - "role": AdminRole.SITEWIDE_LIBRARY_MANAGER, - } - ], - }, - ], + expected_admins, key=lambda x: x["email"], ) == sorted(admins, key=lambda x: x["email"]) - def test_individual_admins_get_no_admin(self, settings_ctrl_fixture): + def test_individual_admins_get_no_admin( + self, + flask_app_fixture: FlaskAppFixture, + controller: IndividualAdminSettingsController, + ): # When the application is first started, there is no admin user. In that # case, we return a problem detail. - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="GET"): - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context("/", method="GET"): + response = controller.process_get() assert response == ADMIN_AUTH_NOT_CONFIGURED - def test_individual_admins_post_errors(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict([]) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + def test_individual_admins_post_errors( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict([]) + response = controller.process_post() assert response.uri == INCOMPLETE_CONFIGURATION.uri - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "test@library.org"), ("password", "334df3f70bfe1979"), @@ -272,14 +284,23 @@ def test_individual_admins_post_errors(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.uri == LIBRARY_NOT_FOUND.uri + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("email", "not-a-email"), + ("password", "334df3f70bfe1979"), + ] + ) + response = controller.process_post() + assert response.uri == INVALID_EMAIL.uri + assert '"not-a-email" is not a valid email address' in response.detail + library = db.library() - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "test@library.org"), ("password", "334df3f70bfe1979"), @@ -291,14 +312,15 @@ def test_individual_admins_post_errors(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.uri == UNKNOWN_ROLE.uri - def test_individual_admins_post_permissions(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - + def test_individual_admins_post_permissions( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): l1 = db.library() l2 = db.library() system, ignore = create(db.session, Admin, email="system@example.com") @@ -341,22 +363,22 @@ def test_individual_admins_post_permissions(self, settings_ctrl_fixture): def test_changing_roles( admin_making_request, target_admin, roles=None, allowed=False ): - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=admin_making_request ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", target_admin.email), ("roles", json.dumps(roles or [])), ] ) if allowed: - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() + controller.process_post() db.session.rollback() else: pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post, + controller.process_post, ) # Various types of user trying to change a system admin's roles @@ -420,10 +442,10 @@ def test_changing_roles( ) def test_changing_password(admin_making_request, target_admin, allowed=False): - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=admin_making_request ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", target_admin.email), ("password", "new password"), @@ -434,12 +456,12 @@ def test_changing_password(admin_making_request, target_admin, allowed=False): ] ) if allowed: - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() + controller.process_post() db.session.rollback() else: pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post, + controller.process_post, ) # Various types of user trying to change a system admin's password @@ -524,11 +546,14 @@ def test_changing_password(admin_making_request, target_admin, allowed=False): test_changing_password(manager1_2, sitewide_manager) test_changing_password(manager1_2, sitewide_librarian) - def test_individual_admins_post_create(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + def test_individual_admins_post_create( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "admin@nypl.org"), ("password", "pass"), @@ -545,13 +570,12 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 201 # The admin was created. admin_match = Admin.authenticate(db.session, "admin@nypl.org", "pass") + assert admin_match is not None assert admin_match.email == response.get_data(as_text=True) assert admin_match assert admin_match.has_password("pass") @@ -561,10 +585,10 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): assert db.default_library() == role.library # The new admin is a library manager, so they can create librarians. - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=admin_match ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "admin2@nypl.org"), ("password", "pass"), @@ -581,12 +605,11 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 201 admin_match = Admin.authenticate(db.session, "admin2@nypl.org", "pass") + assert admin_match is not None assert admin_match.email == response.get_data(as_text=True) assert admin_match assert admin_match.has_password("pass") @@ -595,9 +618,12 @@ def test_individual_admins_post_create(self, settings_ctrl_fixture): assert AdminRole.LIBRARIAN == role.role assert db.default_library() == role.library - def test_individual_admins_post_edit(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - + def test_individual_admins_post_edit( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): # An admin exists. admin, ignore = create( db.session, @@ -607,8 +633,8 @@ def test_individual_admins_post_edit(self, settings_ctrl_fixture): admin.password = "password" admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "admin@nypl.org"), ("password", "new password"), @@ -626,9 +652,7 @@ def test_individual_admins_post_edit(self, settings_ctrl_fixture): ), ] ) - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 200 assert admin.email == response.get_data(as_text=True) @@ -646,15 +670,18 @@ def test_individual_admins_post_edit(self, settings_ctrl_fixture): # The roles were changed. assert False == admin.is_system_admin() - [librarian_all, manager] = sorted(admin.roles, key=lambda x: x.role) + [librarian_all, manager] = sorted(admin.roles, key=lambda x: str(x.role)) assert AdminRole.SITEWIDE_LIBRARIAN == librarian_all.role assert None == librarian_all.library assert AdminRole.LIBRARY_MANAGER == manager.role assert db.default_library() == manager.library - def test_individual_admin_delete(self, settings_ctrl_fixture): - db = settings_ctrl_fixture.ctrl.db - + def test_individual_admin_delete( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): librarian, ignore = create(db.session, Admin, email=db.fresh_str()) librarian.password = "password" librarian.add_role(AdminRole.LIBRARIAN, db.default_library()) @@ -665,35 +692,31 @@ def test_individual_admin_delete(self, settings_ctrl_fixture): system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="DELETE", admin=librarian ): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete, + controller.process_delete, librarian.email, ) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="DELETE", admin=sitewide_manager ): - response = settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete( - librarian.email - ) + response = controller.process_delete(librarian.email) assert response.status_code == 200 pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete, + controller.process_delete, system_admin.email, ) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="DELETE", admin=system_admin ): - response = settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_delete( - system_admin.email - ) + response = controller.process_delete(system_admin.email) assert response.status_code == 200 admin = get_one(db.session, Admin, id=librarian.id) @@ -702,15 +725,19 @@ def test_individual_admin_delete(self, settings_ctrl_fixture): admin = get_one(db.session, Admin, id=system_admin.id) assert None == admin - def test_individual_admins_post_create_not_system(self, settings_ctrl_fixture): + def test_individual_admins_post_create_not_system( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): """Creating an admin that's not a system admin will fail.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("password", "pass"), @@ -727,82 +754,85 @@ def test_individual_admins_post_create_not_system(self, settings_ctrl_fixture): ), ] ) - flask.request.files = {} + flask.request.files = ImmutableMultiDict() pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post, + controller.process_post, ) def test_individual_admins_post_create_requires_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """The password is required.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("roles", json.dumps([{"role": AdminRole.SYSTEM_ADMIN}])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code assert response.uri == INCOMPLETE_CONFIGURATION.uri def test_individual_admins_post_create_requires_non_empty_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """The password is required.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("password", ""), ("roles", json.dumps([{"role": AdminRole.SYSTEM_ADMIN}])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code assert response.uri == INCOMPLETE_CONFIGURATION.uri - def test_individual_admins_post_create_on_setup(self, settings_ctrl_fixture): + def test_individual_admins_post_create_on_setup( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): """Creating a system admin with a password works.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) - with settings_ctrl_fixture.ctrl.app.test_request_context("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("email", "first_admin@nypl.org"), ("password", "pass"), ("roles", json.dumps([{"role": AdminRole.SYSTEM_ADMIN}])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 201 == response.status_code # The admin was created. admin_match = Admin.authenticate(db.session, "first_admin@nypl.org", "pass") + assert admin_match is not None assert admin_match.email == response.get_data(as_text=True) assert admin_match assert admin_match.has_password("pass") @@ -810,9 +840,13 @@ def test_individual_admins_post_create_on_setup(self, settings_ctrl_fixture): [role] = admin_match.roles assert AdminRole.SYSTEM_ADMIN == role.role - def test_individual_admins_post_create_second_admin(self, settings_ctrl_fixture): + def test_individual_admins_post_create_second_admin( + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, + ): """Creating a second admin with a password works.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -820,27 +854,27 @@ def test_individual_admins_post_create_second_admin(self, settings_ctrl_fixture) system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), ("password", "pass"), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 201 == response.status_code def test_individual_admins_post_create_second_admin_no_roles( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin with a password works.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -848,23 +882,23 @@ def test_individual_admins_post_create_second_admin_no_roles( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [("email", "second_admin@nypl.org"), ("password", "pass")] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 201 == response.status_code def test_individual_admins_post_create_second_admin_no_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin without a password fails.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -872,26 +906,26 @@ def test_individual_admins_post_create_second_admin_no_password( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code def test_individual_admins_post_create_second_admin_empty_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin without a password fails.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -899,27 +933,27 @@ def test_individual_admins_post_create_second_admin_empty_password( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), ("password", ""), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code def test_individual_admins_post_create_second_admin_blank_password( - self, settings_ctrl_fixture + self, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + controller: IndividualAdminSettingsController, ): """Creating a second admin without a password fails.""" - db = settings_ctrl_fixture.ctrl.db for admin in db.session.query(Admin): db.session.delete(admin) @@ -927,18 +961,16 @@ def test_individual_admins_post_create_second_admin_blank_password( system_admin, ignore = create(db.session, Admin, email=db.fresh_str()) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin( + with flask_app_fixture.test_request_context( "/", method="POST", admin=system_admin ): - flask.request.form = MultiDict( + flask.request.form = ImmutableMultiDict( [ ("email", "second_admin@nypl.org"), ("password", " "), - ("roles", []), + ("roles", json.dumps([])), ] ) - flask.request.files = {} - response = ( - settings_ctrl_fixture.manager.admin_individual_admin_settings_controller.process_post() - ) + flask.request.files = ImmutableMultiDict() + response = controller.process_post() assert 400 == response.status_code diff --git a/tests/api/admin/controller/test_library.py b/tests/api/admin/controller/test_library.py index 6cc878577..0cf36be50 100644 --- a/tests/api/admin/controller/test_library.py +++ b/tests/api/admin/controller/test_library.py @@ -4,7 +4,8 @@ import datetime import json from io import BytesIO -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import MagicMock, create_autospec import flask import pytest @@ -30,13 +31,21 @@ from core.model.library import LibraryLogo from core.util.problem_detail import ProblemDetail, ProblemError from tests.fixtures.announcements import AnnouncementFixture -from tests.fixtures.api_controller import ControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture from tests.fixtures.library import LibraryFixture +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> LibrarySettingsController: + mock_manager = MagicMock() + mock_manager._db = db.session + return LibrarySettingsController(mock_manager) + + class TestLibrarySettings: @pytest.fixture() - def logo_properties(self): + def logo_properties(self) -> dict[str, Any]: image_data_raw = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00\x00\x00%\xdbV\xca\x00\x00\x00\x06PLTE\xffM\x00\x01\x01\x01\x8e\x1e\xe5\x1b\x00\x00\x00\x01tRNS\xcc\xd24V\xfd\x00\x00\x00\nIDATx\x9cc`\x00\x00\x00\x02\x00\x01H\xaf\xa4q\x00\x00\x00\x00IEND\xaeB`\x82" image_data_b64_bytes = base64.b64encode(image_data_raw) image_data_b64_unicode = image_data_b64_bytes.decode("utf-8") @@ -52,7 +61,7 @@ def logo_properties(self): def library_form( self, library: Library, fields: dict[str, str | list[str]] | None = None - ): + ) -> ImmutableMultiDict[str, str]: fields = fields or {} defaults: dict[str, str | list[str]] = { "uuid": str(library.uuid), @@ -75,39 +84,45 @@ def library_form( form = ImmutableMultiDict(form_data) return form - def test_libraries_get_with_no_libraries(self, settings_ctrl_fixture): + def test_libraries_get_with_no_libraries( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): # Delete any existing library created by the controller test setup. - library = get_one(settings_ctrl_fixture.ctrl.db.session, Library) + library = get_one(db.session, Library) if library: - settings_ctrl_fixture.ctrl.db.session.delete(library) + db.session.delete(library) - with settings_ctrl_fixture.ctrl.app.test_request_context("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_get() + assert isinstance(response.json, dict) assert response.json.get("libraries") == [] def test_libraries_get_with_announcements( - self, settings_ctrl_fixture, announcement_fixture: AnnouncementFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + announcement_fixture: AnnouncementFixture, ): - db = settings_ctrl_fixture.ctrl.db # Delete any existing library created by the controller test setup. library = get_one(db.session, Library) if library: db.session.delete(library) # Set some announcements for this library. - test_library = settings_ctrl_fixture.ctrl.db.library("Library 1", "L1") + test_library = db.library("Library 1", "L1") a1 = announcement_fixture.active_announcement(db.session, test_library) a2 = announcement_fixture.expired_announcement(db.session, test_library) a3 = announcement_fixture.forthcoming_announcement(db.session, test_library) # When we request information about this library... - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) - library_settings = response.json.get("libraries")[0].get("settings") + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_get() + assert isinstance(response.json, dict) + library_settings = response.json.get("libraries", [])[0].get("settings") # We find out about the library's announcements. announcements = library_settings.get(ANNOUNCEMENTS_SETTING_NAME) @@ -130,21 +145,23 @@ def test_libraries_get_with_announcements( datetime.date, ) - def test_libraries_get_with_logo(self, settings_ctrl_fixture, logo_properties): - db = settings_ctrl_fixture.ctrl.db - + def test_libraries_get_with_logo( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + logo_properties: dict[str, Any], + ): library = db.default_library() # Give the library a logo library.logo = LibraryLogo(content=logo_properties["base64_bytes"]) # When we request information about this library... - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) - - libraries = response.json.get("libraries") + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_get() + assert isinstance(response.json, dict) + libraries = response.json.get("libraries", []) assert len(libraries) == 1 library_settings = libraries[0].get("settings") @@ -152,12 +169,16 @@ def test_libraries_get_with_logo(self, settings_ctrl_fixture, logo_properties): assert library_settings["logo"] == logo_properties["data_url"] def test_libraries_get_with_multiple_libraries( - self, settings_ctrl_fixture, library_fixture: LibraryFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + library_fixture: LibraryFixture, ): # Delete any existing library created by the controller test setup. - library = get_one(settings_ctrl_fixture.ctrl.db.session, Library) + library = get_one(db.session, Library) if library: - settings_ctrl_fixture.ctrl.db.session.delete(library) + db.session.delete(library) l1 = library_fixture.library("Library 1", "L1") l2 = library_fixture.library("Library 2", "L2") @@ -175,15 +196,15 @@ def test_libraries_get_with_multiple_libraries( l2.update_settings(settings) # The admin only has access to L1 and L2. - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.admin.add_role(AdminRole.LIBRARIAN, l1) - settings_ctrl_fixture.admin.add_role(AdminRole.LIBRARY_MANAGER, l2) - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_get() - ) - libraries = response.json.get("libraries") + admin = flask_app_fixture.admin_user() + admin.remove_role(AdminRole.SYSTEM_ADMIN) + admin.add_role(AdminRole.LIBRARIAN, l1) + admin.add_role(AdminRole.LIBRARY_MANAGER, l2) + + with flask_app_fixture.test_request_context("/", admin=admin): + response = controller.process_get() + assert isinstance(response.json, dict) + libraries = response.json.get("libraries", []) assert 2 == len(libraries) assert l1.uuid == libraries[0].get("uuid") @@ -211,77 +232,82 @@ def test_libraries_get_with_multiple_libraries( ] == settings_dict.get("facets_enabled_order") assert ["fre"] == settings_dict.get("large_collection_languages") - def test_libraries_post_errors(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + def test_libraries_post_errors( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict([]) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri assert ( "Required field 'Name' is missing." == excinfo.value.problem_detail.detail ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Brooklyn Public Library"), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri assert ( "Required field 'Short name' is missing." == excinfo.value.problem_detail.detail ) - library = settings_ctrl_fixture.ctrl.db.library() - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + library = db.library() + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form(library, {"uuid": "1234"}) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == LIBRARY_NOT_FOUND.uri - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Brooklyn Public Library"), - ("short_name", library.short_name), + ("short_name", str(library.short_name)), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail == LIBRARY_SHORT_NAME_ALREADY_IN_USE - bpl = settings_ctrl_fixture.ctrl.db.library(short_name="bpl") - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + bpl = db.library(short_name="bpl") + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ - ("uuid", bpl.uuid), + ("uuid", str(bpl.uuid)), ("name", "Brooklyn Public Library"), - ("short_name", library.short_name), + ("short_name", str(library.short_name)), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail == LIBRARY_SHORT_NAME_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ - ("uuid", library.uuid), + ("uuid", str(library.uuid)), ("name", "The New York Public Library"), - ("short_name", library.short_name), + ("short_name", str(library.short_name)), ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri # Either patron support email or website MUST be present - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "Email or Website Library"), @@ -291,8 +317,9 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): ] ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INCOMPLETE_CONFIGURATION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "'Patron support email address' or 'Patron support website'" in excinfo.value.problem_detail.detail @@ -300,7 +327,7 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): # Test a web primary and secondary color that doesn't contrast # well on white. Here primary will, secondary should not. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form( library, { @@ -309,8 +336,9 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): }, ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "contrast-ratio.com/#%23e0e0e0-on-%23ffffff" in excinfo.value.problem_detail.detail @@ -322,7 +350,7 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): # Test a list of web header links and a list of labels that # aren't the same length. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form( library, { @@ -334,24 +362,25 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): }, ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri # Test bad language code - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form( library, {"tiny_collection_languages": "zzz"} ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == UNKNOWN_LANGUAGE.uri + assert excinfo.value.problem_detail.detail is not None assert ( '"zzz" is not a valid language code' in excinfo.value.problem_detail.detail ) # Test uploading a logo that is in the wrong format. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form(library) flask.request.files = ImmutableMultiDict( { @@ -363,15 +392,16 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): } ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "Image upload must be in GIF, PNG, or JPG format." in excinfo.value.problem_detail.detail ) # Test uploading a logo that we can't open to resize. - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = self.library_form(library) flask.request.files = ImmutableMultiDict( { @@ -383,13 +413,14 @@ def test_libraries_post_errors(self, settings_ctrl_fixture): } ) with pytest.raises(ProblemError) as excinfo: - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() + controller.process_post() assert excinfo.value.problem_detail.uri == INVALID_CONFIGURATION_OPTION.uri + assert excinfo.value.problem_detail.detail is not None assert ( "Unable to open uploaded image" in excinfo.value.problem_detail.detail ) - def test__process_image(self, logo_properties, settings_ctrl_fixture): + def test__process_image(self, logo_properties: dict[str, Any]): image, expected_encoded_image = ( logo_properties[key] for key in ("image", "base64_bytes") ) @@ -408,12 +439,12 @@ def test__process_image(self, logo_properties, settings_ctrl_fixture): def test_libraries_post_create( self, - logo_properties, - settings_ctrl_fixture, + logo_properties: dict[str, Any], + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, announcement_fixture: AnnouncementFixture, ): - db = settings_ctrl_fixture.ctrl.db - # Pull needed properties from logo fixture image_data, expected_logo_data_url, image = ( logo_properties[key] for key in ("raw_bytes", "data_url", "image") @@ -423,7 +454,7 @@ def test_libraries_post_create( # a mismatch between the expected data URL and the one configured. assert max(*image.size) <= Configuration.LOGO_MAX_DIMENSION - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "The New York Public Library"), @@ -477,9 +508,7 @@ def test_libraries_post_create( ) } ) - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 201 library = get_one(db.session, Library, short_name="nypl") @@ -532,7 +561,11 @@ def test_libraries_post_create( assert ["ger"] == german.languages def test_libraries_post_edit( - self, settings_ctrl_fixture, library_fixture: LibraryFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + library_fixture: LibraryFixture, ): # A library already exists. settings = library_fixture.mock_settings() @@ -548,7 +581,7 @@ def test_libraries_post_edit( library_to_edit.logo = LibraryLogo(content=b"A tiny image") library_fixture.reset_settings_cache(library_to_edit) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("uuid", str(library_to_edit.uuid)), @@ -576,15 +609,10 @@ def test_libraries_post_edit( ), ] ) - flask.request.files = ImmutableMultiDict([]) - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 200 - library = get_one( - settings_ctrl_fixture.ctrl.db.session, Library, uuid=library_to_edit.uuid - ) + library = get_one(db.session, Library, uuid=library_to_edit.uuid) assert library is not None assert library.uuid == response.get_data(as_text=True) @@ -609,7 +637,11 @@ def test_libraries_post_edit( assert library.logo.content == b"A tiny image" def test_library_post_empty_values_edit( - self, settings_ctrl_fixture, library_fixture: LibraryFixture + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + library_fixture: LibraryFixture, ): settings = library_fixture.mock_settings() settings.library_description = "description" @@ -618,7 +650,7 @@ def test_library_post_empty_values_edit( ) library_fixture.reset_settings_cache(library_to_edit) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("uuid", str(library_to_edit.uuid)), @@ -629,19 +661,20 @@ def test_library_post_empty_values_edit( ("help_email", "help@example.com"), ] ) - response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response = controller.process_post() assert response.status_code == 200 - library = get_one( - settings_ctrl_fixture.ctrl.db.session, Library, uuid=library_to_edit.uuid - ) + library = get_one(db.session, Library, uuid=library_to_edit.uuid) assert library is not None assert library.settings.library_description is None - def test_library_post_empty_values_create(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): + def test_library_post_empty_values_create( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("name", "The New York Public Library"), @@ -651,75 +684,76 @@ def test_library_post_empty_values_create(self, settings_ctrl_fixture): ("help_email", "help@example.com"), ] ) - response: Response = ( - settings_ctrl_fixture.manager.admin_library_settings_controller.process_post() - ) + response: Response = controller.process_post() assert response.status_code == 201 uuid = response.get_data(as_text=True) - library = get_one(settings_ctrl_fixture.ctrl.db.session, Library, uuid=uuid) + library = get_one(db.session, Library, uuid=uuid) + assert library is not None assert library.settings.library_description is None - def test_library_delete(self, settings_ctrl_fixture): - library = settings_ctrl_fixture.ctrl.db.library() + def test_library_delete( + self, + flask_app_fixture: FlaskAppFixture, + controller: LibrarySettingsController, + db: DatabaseTransactionFixture, + ): + library = db.library() - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_library_settings_controller.process_delete, + controller.process_delete, library.uuid, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_library_settings_controller.process_delete( - library.uuid - ) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + response = controller.process_delete(str(library.uuid)) assert response.status_code == 200 - library = get_one( - settings_ctrl_fixture.ctrl.db.session, Library, uuid=library.uuid - ) - assert None == library + queried_library = get_one(db.session, Library, uuid=library.uuid) + assert queried_library is None - def test_process_libraries(self, controller_fixture: ControllerFixture): - manager = MagicMock() - controller = LibrarySettingsController(manager) - controller.process_get = MagicMock() - controller.process_post = MagicMock() + def test_process_libraries( + self, flask_app_fixture: FlaskAppFixture, controller: LibrarySettingsController + ): + mock_process_get = create_autospec(controller.process_get) + controller.process_get = mock_process_get + mock_process_post = create_autospec(controller.process_post) + controller.process_post = mock_process_post # Make sure we call process_get for a get request - with controller_fixture.request_context_with_library("/", method="GET"): + with flask_app_fixture.test_request_context("/", method="GET"): controller.process_libraries() - controller.process_get.assert_called_once() - controller.process_post.assert_not_called() - controller.process_get.reset_mock() - controller.process_post.reset_mock() + mock_process_get.assert_called_once() + mock_process_post.assert_not_called() + mock_process_get.reset_mock() + mock_process_post.reset_mock() # Make sure we call process_post for a post request - with controller_fixture.request_context_with_library("/", method="POST"): + with flask_app_fixture.test_request_context("/", method="POST"): controller.process_libraries() - controller.process_get.assert_not_called() - controller.process_post.assert_called_once() - controller.process_get.reset_mock() - controller.process_post.reset_mock() + mock_process_get.assert_not_called() + mock_process_post.assert_called_once() + mock_process_get.reset_mock() + mock_process_post.reset_mock() # For any other request, make sure we return a ProblemDetail - with controller_fixture.request_context_with_library("/", method="PUT"): + with flask_app_fixture.test_request_context("/", method="PUT"): resp = controller.process_libraries() - controller.process_get.assert_not_called() - controller.process_post.assert_not_called() + mock_process_get.assert_not_called() + mock_process_post.assert_not_called() assert isinstance(resp, ProblemDetail) # Make sure that if process_get or process_post raises a ProblemError, # we return the problem detail. - controller.process_get.side_effect = ProblemError( + mock_process_get.side_effect = ProblemError( problem_detail=INCOMPLETE_CONFIGURATION.detailed("test") ) - with controller_fixture.request_context_with_library("/", method="GET"): + with flask_app_fixture.test_request_context("/", method="GET"): resp = controller.process_libraries() assert isinstance(resp, ProblemDetail) assert resp.detail == "test" diff --git a/tests/api/admin/controller/test_library_registrations.py b/tests/api/admin/controller/test_library_registrations.py index 889b566c9..f5d54bb40 100644 --- a/tests/api/admin/controller/test_library_registrations.py +++ b/tests/api/admin/controller/test_library_registrations.py @@ -6,11 +6,14 @@ from requests_mock import Mocker from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.discovery_service_library_registrations import ( + DiscoveryServiceLibraryRegistrationsController, +) from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import MISSING_SERVICE, NO_SUCH_LIBRARY from api.discovery.opds_registration import OpdsRegistrationService from api.problem_details import REMOTE_INTEGRATION_FAILED -from core.model import AdminRole, create +from core.model import create from core.model.discovery_service_registration import ( DiscoveryServiceRegistration, RegistrationStage, @@ -18,23 +21,35 @@ ) from core.problem_details import INVALID_INPUT from core.util.problem_detail import ProblemDetail, ProblemError -from tests.fixtures.api_admin import AdminControllerFixture -from tests.fixtures.database import IntegrationConfigurationFixture +from tests.fixtures.database import ( + DatabaseTransactionFixture, + IntegrationConfigurationFixture, +) +from tests.fixtures.flask import FlaskAppFixture from tests.fixtures.library import LibraryFixture +@pytest.fixture +def controller( + db: DatabaseTransactionFixture, +) -> DiscoveryServiceLibraryRegistrationsController: + mock_manager = MagicMock() + mock_manager._db = db.session + return DiscoveryServiceLibraryRegistrationsController(mock_manager) + + class TestLibraryRegistration: """Test the process of registering a library with a OpdsRegistrationService.""" def test_discovery_service_library_registrations_get( self, - admin_ctrl_fixture: AdminControllerFixture, + controller: DiscoveryServiceLibraryRegistrationsController, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, create_integration_configuration: IntegrationConfigurationFixture, library_fixture: LibraryFixture, requests_mock: Mocker, ) -> None: - db = admin_ctrl_fixture.ctrl.db - # Here's a discovery service. discovery_service = create_integration_configuration.discovery_service( url="http://service-url.com/" @@ -112,20 +127,18 @@ def test_discovery_service_library_registrations_get( headers={"Content-Type": OpdsRegistrationService.OPDS_2_TYPE}, ) - controller = ( - admin_ctrl_fixture.ctrl.manager.admin_discovery_service_library_registrations_controller - ) - m = controller.process_discovery_service_library_registrations - with admin_ctrl_fixture.request_context_with_admin("/", method="GET"): + with flask_app_fixture.test_request_context("/", method="GET"): # When the user lacks the SYSTEM_ADMIN role, the # controller won't even start processing their GET # request. - pytest.raises(AdminNotAuthorized, m) - - # Add the admin role and try again. - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + pytest.raises( + AdminNotAuthorized, + controller.process_discovery_service_library_registrations, + ) - response = m() + # Request again with system admin role + with flask_app_fixture.test_request_context_system_admin("/", method="GET"): + response = controller.process_discovery_service_library_registrations() # The document we get back from the controller is a # dictionary with useful information on all known # discovery integrations -- just one, in this case. @@ -179,7 +192,7 @@ def test_discovery_service_library_registrations_get( status_code=502, ) - response = m() + response = controller.process_discovery_service_library_registrations() # Everything looks good, except that there's no TOS data # available. @@ -198,7 +211,8 @@ def test_discovery_service_library_registrations_get( def test_discovery_service_library_registrations_post( self, - admin_ctrl_fixture: AdminControllerFixture, + controller: DiscoveryServiceLibraryRegistrationsController, + flask_app_fixture: FlaskAppFixture, create_integration_configuration: IntegrationConfigurationFixture, library_fixture: LibraryFixture, ) -> None: @@ -206,34 +220,30 @@ def test_discovery_service_library_registrations_post( discovery_service_library_registrations. """ - controller = ( - admin_ctrl_fixture.manager.admin_discovery_service_library_registrations_controller - ) - m = controller.process_discovery_service_library_registrations - # Here, the user doesn't have permission to start the # registration process. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): - pytest.raises(AdminNotAuthorized, m) - - admin_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="POST"): + pytest.raises( + AdminNotAuthorized, + controller.process_discovery_service_library_registrations, + ) # We might not get an integration ID parameter. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict() - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri # The integration ID might not correspond to a valid # ExternalIntegration. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", "1234"), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert MISSING_SERVICE == response @@ -243,44 +253,44 @@ def test_discovery_service_library_registrations_post( ) # We might not get a library short name. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri # The library name might not correspond to a real library. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), ("library_short_name", "not-a-library"), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert NO_SUCH_LIBRARY == response # Take care of that problem. library = library_fixture.library() # We might not get a registration stage. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), ("library_short_name", str(library.short_name)), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri # The registration stage might not be valid. - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = ImmutableMultiDict( [ ("integration_id", str(discovery_service.id)), @@ -288,7 +298,7 @@ def test_discovery_service_library_registrations_post( ("registration_stage", "not-a-stage"), ] ) - response = m() + response = controller.process_discovery_service_library_registrations() assert isinstance(response, ProblemDetail) assert INVALID_INPUT.uri == response.uri @@ -307,9 +317,9 @@ def test_discovery_service_library_registrations_post( ) controller.look_up_registry = MagicMock(return_value=mock_registry) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = form - response = m() + response = controller.process_discovery_service_library_registrations() assert REMOTE_INTEGRATION_FAILED == response # But if that doesn't happen, success! @@ -317,7 +327,7 @@ def test_discovery_service_library_registrations_post( mock_registry.register_library.return_value = True controller.look_up_registry = MagicMock(return_value=mock_registry) - with admin_ctrl_fixture.request_context_with_admin("/", method="POST"): + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): flask.request.form = form response = controller.process_discovery_service_library_registrations() assert isinstance(response, Response) diff --git a/tests/api/admin/controller/test_metadata_service_self_tests.py b/tests/api/admin/controller/test_metadata_service_self_tests.py deleted file mode 100644 index d1f056035..000000000 --- a/tests/api/admin/controller/test_metadata_service_self_tests.py +++ /dev/null @@ -1,94 +0,0 @@ -from api.admin.problem_details import * -from api.nyt import NYTBestSellerAPI -from core.model import ExternalIntegration, create -from core.selftest import HasSelfTests - - -class TestMetadataServiceSelfTests: - def test_metadata_service_self_tests_with_no_identifier( - self, settings_ctrl_fixture - ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( - None - ) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_metadata_service_self_tests_with_no_metadata_service_found( - self, settings_ctrl_fixture - ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( - -1 - ) - assert response == MISSING_SERVICE - assert response.status_code == 404 - - def test_metadata_service_self_tests_test_get(self, settings_ctrl_fixture): - old_prior_test_results = HasSelfTests.prior_test_results - HasSelfTests.prior_test_results = settings_ctrl_fixture.mock_prior_test_results - metadata_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.NYT, - goal=ExternalIntegration.METADATA_GOAL, - ) - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's self tests object. - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.process_metadata_service_self_tests( - metadata_service.id - ) - response_metadata_service = response.get("self_test_results") - - assert response_metadata_service.get("id") == metadata_service.id - assert response_metadata_service.get("name") == metadata_service.name - assert ( - response_metadata_service.get("protocol").get("label") - == NYTBestSellerAPI.NAME - ) - assert response_metadata_service.get("goal") == metadata_service.goal - assert ( - response_metadata_service.get("self_test_results") - == HasSelfTests.prior_test_results() - ) - HasSelfTests.prior_test_results = old_prior_test_results - - def test_metadata_service_self_tests_post(self, settings_ctrl_fixture): - old_run_self_tests = HasSelfTests.run_self_tests - HasSelfTests.run_self_tests = settings_ctrl_fixture.mock_run_self_tests - - metadata_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.NYT, - goal=ExternalIntegration.METADATA_GOAL, - ) - m = ( - settings_ctrl_fixture.manager.admin_metadata_service_self_tests_controller.self_tests_process_post - ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - response = m(metadata_service.id) - assert response._status == "200 OK" - assert "Successfully ran new self tests" == response.get_data(as_text=True) - - positional, keyword = settings_ctrl_fixture.run_self_tests_called_with - # run_self_tests was called with positional arguments: - # * The database connection - # * The method to call to instantiate a HasSelfTests implementation - # (NYTBestSellerAPI.from_config) - # * The database connection again (to be passed into - # NYTBestSellerAPI.from_config). - assert ( - settings_ctrl_fixture.ctrl.db.session, - NYTBestSellerAPI.from_config, - settings_ctrl_fixture.ctrl.db.session, - ) == positional - - # run_self_tests was not called with any keyword arguments. - assert {} == keyword - - # Undo the mock. - HasSelfTests.run_self_tests = old_run_self_tests diff --git a/tests/api/admin/controller/test_metadata_services.py b/tests/api/admin/controller/test_metadata_services.py index ba62edcf4..54fa37fb2 100644 --- a/tests/api/admin/controller/test_metadata_services.py +++ b/tests/api/admin/controller/test_metadata_services.py @@ -1,115 +1,168 @@ import json +from unittest.mock import MagicMock, create_autospec import flask import pytest -from werkzeug.datastructures import MultiDict +from _pytest.monkeypatch import MonkeyPatch +from flask import Response +from werkzeug.datastructures import ImmutableMultiDict from api.admin.controller.metadata_services import MetadataServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, + DUPLICATE_INTEGRATION, + FAILED_TO_RUN_SELF_TESTS, INCOMPLETE_CONFIGURATION, INTEGRATION_NAME_ALREADY_IN_USE, + MISSING_IDENTIFIER, MISSING_SERVICE, + MISSING_SERVICE_NAME, NO_PROTOCOL_FOR_NEW_SERVICE, NO_SUCH_LIBRARY, UNKNOWN_PROTOCOL, ) -from api.novelist import NoveListAPI -from api.nyt import NYTBestSellerAPI -from core.model import AdminRole, ExternalIntegration, create, get_one +from api.integration.registry.metadata import MetadataRegistry +from api.metadata.novelist import NoveListAPI, NoveListApiSettings +from api.metadata.nyt import NYTBestSellerAPI, NytBestSellerApiSettings +from core.integration.goals import Goals +from core.model import IntegrationConfiguration, get_one +from core.util.problem_detail import ProblemDetail +from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.flask import FlaskAppFixture + + +class MetadataServicesFixture: + def __init__(self, db: DatabaseTransactionFixture): + self.registry = MetadataRegistry() + + novelist_protocol = self.registry.get_protocol(NoveListAPI) + assert novelist_protocol is not None + self.novelist_protocol = novelist_protocol + + nyt_protocol = self.registry.get_protocol(NYTBestSellerAPI) + assert nyt_protocol is not None + self.nyt_protocol = nyt_protocol + + manager = MagicMock() + manager._db = db.session + self.controller = MetadataServicesController(manager, self.registry) + self.db = db + + def create_novelist_integration( + self, + username: str = "user", + password: str = "pass", + ) -> IntegrationConfiguration: + integration = self.db.integration_configuration( + protocol=self.novelist_protocol, + goal=Goals.METADATA_GOAL, + ) + settings = NoveListApiSettings(username=username, password=password) + NoveListAPI.settings_update(integration, settings) + return integration + + def create_nyt_integration( + self, + api_key: str = "xyz", + ) -> IntegrationConfiguration: + integration = self.db.integration_configuration( + protocol=self.nyt_protocol, + goal=Goals.METADATA_GOAL, + ) + settings = NytBestSellerApiSettings(password=api_key) + NYTBestSellerAPI.settings_update(integration, settings) + return integration -class TestMetadataServices: - def create_service(self, name, db_session): - return create( - db_session, - ExternalIntegration, - protocol=ExternalIntegration.__dict__.get(name) or "fake", - goal=ExternalIntegration.METADATA_GOAL, - )[0] +@pytest.fixture +def metadata_services_fixture( + db: DatabaseTransactionFixture, +) -> MetadataServicesFixture: + return MetadataServicesFixture(db) + +class TestMetadataServices: def test_process_metadata_services_dispatches_by_request_method( - self, settings_ctrl_fixture + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, ): - class Mock(MetadataServicesController): - def process_get(self): - return "GET" - - def process_post(self): - return "POST" + controller = metadata_services_fixture.controller - controller = Mock(settings_ctrl_fixture.manager) - with settings_ctrl_fixture.request_context_with_admin("/"): - assert "GET" == controller.process_metadata_services() - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - assert "POST" == controller.process_metadata_services() - - # This is also where permissions are checked. - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() - - with settings_ctrl_fixture.request_context_with_admin("/"): + # Make sure permissions are checked. + with flask_app_fixture.test_request_context("/"): pytest.raises(AdminNotAuthorized, controller.process_metadata_services) - def test_process_get_with_no_services(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_metadata_services_controller.process_get() - ) - assert response.get("metadata_services") == [] - protocols = response.get("protocols") - assert NoveListAPI.NAME in [p.get("label") for p in protocols] - assert "settings" in protocols[0] - - def test_process_get_with_one_service(self, settings_ctrl_fixture): - novelist_service = self.create_service( - "NOVELIST", settings_ctrl_fixture.ctrl.db.session - ) - novelist_service.username = "user" - novelist_service.password = "pass" - - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = controller.process_get() - [service] = response.get("metadata_services") - - assert novelist_service.id == service.get("id") - assert ExternalIntegration.NOVELIST == service.get("protocol") - assert "user" == service.get("settings").get(ExternalIntegration.USERNAME) - assert "pass" == service.get("settings").get(ExternalIntegration.PASSWORD) - - novelist_service.libraries += [settings_ctrl_fixture.ctrl.db.default_library()] - with settings_ctrl_fixture.request_context_with_admin("/"): - response = controller.process_get() - [service] = response.get("metadata_services") - - assert "user" == service.get("settings").get(ExternalIntegration.USERNAME) - [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) - - def test_find_protocol_class(self, settings_ctrl_fixture): - [nyt, novelist, fake] = [ - self.create_service(x, settings_ctrl_fixture.ctrl.db.session) - for x in ["NYT", "NOVELIST", "FAKE"] - ] - m = ( - settings_ctrl_fixture.manager.admin_metadata_services_controller.find_protocol_class - ) - - assert m(nyt)[0] == NYTBestSellerAPI - assert m(novelist)[0] == NoveListAPI - pytest.raises(NotImplementedError, m, fake) - - def test_metadata_services_post_errors(self, settings_ctrl_fixture): - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + # Mock out the process_get and process_post methods so we can + # verify that they're called. + controller.process_get = MagicMock() + controller.process_post = MagicMock() + + with flask_app_fixture.test_request_context_system_admin("/"): + controller.process_metadata_services() + controller.process_get.assert_called_once() + controller.process_post.assert_not_called() + + controller.process_get = MagicMock() + controller.process_post = MagicMock() + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + controller.process_metadata_services() + controller.process_get.assert_not_called() + controller.process_post.assert_called_once() + + def test_process_get_with_no_services( + self, metadata_services_fixture: MetadataServicesFixture + ): + response = metadata_services_fixture.controller.process_get() + response_content = response.json + assert isinstance(response_content, dict) + assert response_content.get("metadata_services") == [] + [nyt, novelist] = response_content.get("protocols", []) + + assert novelist.get("name") == metadata_services_fixture.novelist_protocol + assert "settings" in novelist + assert novelist.get("sitewide") is False + + assert nyt.get("name") == metadata_services_fixture.nyt_protocol + assert "settings" in nyt + assert nyt.get("sitewide") is True + + def test_process_get_with_one_service( + self, + metadata_services_fixture: MetadataServicesFixture, + db: DatabaseTransactionFixture, + ): + novelist_service = metadata_services_fixture.create_novelist_integration() + controller = metadata_services_fixture.controller + + response = controller.process_get() + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("metadata_services", []) + + assert service.get("id") == novelist_service.id + assert service.get("protocol") == metadata_services_fixture.novelist_protocol + assert service.get("settings").get("username") == "user" + assert service.get("settings").get("password") == "pass" + + novelist_service.libraries += [db.default_library()] + response = controller.process_get() + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("metadata_services", []) + + [library] = service.get("libraries") + assert library.get("short_name") == db.default_library().short_name + + def test_metadata_services_post_errors( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), ("protocol", "Unknown"), @@ -118,204 +171,425 @@ def test_metadata_services_post_errors(self, settings_ctrl_fixture): response = controller.process_post() assert response == UNKNOWN_PROTOCOL - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict([]) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", metadata_services_fixture.novelist_protocol), + ] + ) response = controller.process_post() - assert response == INCOMPLETE_CONFIGURATION + assert isinstance(response, ProblemDetail) + assert response == MISSING_SERVICE_NAME - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response == NO_PROTOCOL_FOR_NEW_SERVICE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), ("id", "123"), - ("protocol", ExternalIntegration.NYT), + ("protocol", metadata_services_fixture.novelist_protocol), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response == MISSING_SERVICE - service = self.create_service("NOVELIST", settings_ctrl_fixture.ctrl.db.session) + service = metadata_services_fixture.create_novelist_integration() service.name = "name" - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ - ("name", service.name), - ("protocol", ExternalIntegration.NYT), + ("name", str(service.name)), + ("protocol", metadata_services_fixture.nyt_protocol), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response == INTEGRATION_NAME_ALREADY_IN_USE - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("id", service.id), - ("protocol", ExternalIntegration.NYT), + ("id", str(service.id)), + ("protocol", metadata_services_fixture.nyt_protocol), ] ) response = controller.process_post() assert response == CANNOT_CHANGE_PROTOCOL - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ - ("id", service.id), - ("protocol", ExternalIntegration.NOVELIST), + ("id", str(service.id)), + ("protocol", metadata_services_fixture.novelist_protocol), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response.uri == INCOMPLETE_CONFIGURATION.uri - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("id", service.id), - ("protocol", ExternalIntegration.NOVELIST), - (ExternalIntegration.USERNAME, "user"), - (ExternalIntegration.PASSWORD, "pass"), + ("id", str(service.id)), + ("protocol", str(service.protocol)), + ("username", "user"), + ("password", "pass"), ("libraries", json.dumps([{"short_name": "not-a-library"}])), ] ) response = controller.process_post() + assert isinstance(response, ProblemDetail) assert response.uri == NO_SUCH_LIBRARY.uri - def test_metadata_services_post_create(self, settings_ctrl_fixture): - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - library = settings_ctrl_fixture.ctrl.db.library( + def test_metadata_services_post_create( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + controller = metadata_services_fixture.controller + library = db.library( name="Library", short_name="L", ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("protocol", ExternalIntegration.NOVELIST), - (ExternalIntegration.USERNAME, "user"), - (ExternalIntegration.PASSWORD, "pass"), + ("protocol", metadata_services_fixture.novelist_protocol), + ("username", "user"), + ("password", "pass"), ("libraries", json.dumps([{"short_name": "L"}])), ] ) response = controller.process_post() + assert isinstance(response, Response) assert response.status_code == 201 - # A new ExternalIntegration has been created based on the submitted + # A new IntegrationConfiguration has been created based on the submitted # information. service = get_one( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - goal=ExternalIntegration.METADATA_GOAL, + db.session, + IntegrationConfiguration, + goal=Goals.METADATA_GOAL, ) - assert service.id == int(response.response[0]) - assert ExternalIntegration.NOVELIST == service.protocol - assert "user" == service.username - assert "pass" == service.password - assert [library] == service.libraries - - def test_metadata_services_post_edit(self, settings_ctrl_fixture): - l1 = settings_ctrl_fixture.ctrl.db.library( + assert service is not None + assert service.id == int(response.get_data(as_text=True)) + assert service.protocol == metadata_services_fixture.novelist_protocol + settings = NoveListAPI.settings_load(service) + assert settings.username == "user" + assert settings.password == "pass" + assert service.libraries == [library] + + def test_metadata_services_post_create_multiple( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + controller = metadata_services_fixture.controller + metadata_services_fixture.create_novelist_integration() + metadata_services_fixture.create_nyt_integration() + + # If we try to create a second NYT service, we'll get an error. + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "Name"), + ("protocol", metadata_services_fixture.nyt_protocol), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, ProblemDetail) + assert response == DUPLICATE_INTEGRATION + + # However we can create a second NoveList service. + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "Name"), + ("protocol", metadata_services_fixture.novelist_protocol), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, Response) + assert response.status_code == 201 + + def test_metadata_services_post_edit( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + l1 = db.library( name="Library 1", short_name="L1", ) - l2 = settings_ctrl_fixture.ctrl.db.library( + l2 = db.library( name="Library 2", short_name="L2", ) - novelist_service = self.create_service( - "NOVELIST", settings_ctrl_fixture.ctrl.db.session + novelist_service = metadata_services_fixture.create_novelist_integration( + username="olduser", password="oldpass" ) - novelist_service.username = "olduser" - novelist_service.password = "oldpass" novelist_service.libraries = [l1] - controller = settings_ctrl_fixture.manager.admin_metadata_services_controller - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( [ ("name", "Name"), - ("id", novelist_service.id), - ("protocol", ExternalIntegration.NOVELIST), - (ExternalIntegration.USERNAME, "user"), - (ExternalIntegration.PASSWORD, "pass"), + ("id", str(novelist_service.id)), + ("protocol", str(novelist_service.protocol)), + ("username", "newuser"), + ("password", "newpass"), ("libraries", json.dumps([{"short_name": "L2"}])), ] ) response = controller.process_post() assert response.status_code == 200 - def test_check_name_unique(self, settings_ctrl_fixture): - kwargs = dict( - protocol=ExternalIntegration.NYT, goal=ExternalIntegration.METADATA_GOAL - ) - - existing_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, + # The existing IntegrationConfiguration has been updated based on the submitted + # information. + settings = NoveListAPI.settings_load(novelist_service) + assert settings.username == "newuser" + assert settings.password == "newpass" + assert novelist_service.libraries == [l2] + + def test_check_name_unique( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + existing_service = db.integration_configuration( + protocol=metadata_services_fixture.novelist_protocol, + goal=Goals.METADATA_GOAL, name="existing service", - **kwargs ) - new_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, + new_service = db.integration_configuration( + protocol=metadata_services_fixture.novelist_protocol, + goal=Goals.METADATA_GOAL, name="new service", - **kwargs - ) - - m = ( - settings_ctrl_fixture.manager.admin_metadata_services_controller.check_name_unique ) # Try to change new service so that it has the same name as existing service # -- this is not allowed. - result = m(new_service, existing_service.name) - assert result == INTEGRATION_NAME_ALREADY_IN_USE + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", str(existing_service.name)), + ("id", str(new_service.id)), + ("protocol", str(new_service.protocol)), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, ProblemDetail) + assert response == INTEGRATION_NAME_ALREADY_IN_USE # Try to edit existing service without changing its name -- this is fine. - assert None == m(existing_service, existing_service.name) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", str(existing_service.name)), + ("id", str(existing_service.id)), + ("protocol", str(new_service.protocol)), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, Response) + assert response.status_code == 200 # Changing the existing service's name is also fine. - assert None == m(existing_service, "new name") + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "New Name"), + ("id", str(existing_service.id)), + ("protocol", str(new_service.protocol)), + ("username", "user"), + ("password", "pass"), + ] + ) + response = controller.process_post() + assert isinstance(response, Response) + assert response.status_code == 200 - def test_metadata_service_delete(self, settings_ctrl_fixture): - l1 = settings_ctrl_fixture.ctrl.db.library( + def test_metadata_service_delete( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + db: DatabaseTransactionFixture, + ): + l1 = db.library( name="Library 1", short_name="L1", ) - novelist_service = self.create_service( - "NOVELIST", settings_ctrl_fixture.ctrl.db.session + novelist_service = metadata_services_fixture.create_novelist_integration( + username="olduser", password="oldpass" ) - novelist_service.username = "olduser" - novelist_service.password = "oldpass" novelist_service.libraries = [l1] - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_metadata_services_controller.process_delete, + controller.process_delete, novelist_service.id, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_metadata_services_controller.process_delete( - novelist_service.id - ) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): + service_id = novelist_service.id + assert isinstance(service_id, int) + response = controller.process_delete(service_id) assert response.status_code == 200 service = get_one( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, + db.session, + IntegrationConfiguration, id=novelist_service.id, ) - assert None == service + assert service is None + + def test_metadata_service_self_tests_with_no_identifier( + self, metadata_services_fixture: MetadataServicesFixture + ): + response = ( + metadata_services_fixture.controller.process_metadata_service_self_tests( + None + ) + ) + assert isinstance(response, ProblemDetail) + assert response.title == MISSING_IDENTIFIER.title + assert response.detail == MISSING_IDENTIFIER.detail + assert response.status_code == 400 + + def test_metadata_service_self_tests_with_no_metadata_service_found( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + with flask_app_fixture.test_request_context("/"): + response = metadata_services_fixture.controller.process_metadata_service_self_tests( + -1 + ) + assert response == MISSING_SERVICE + assert response.status_code == 404 + + def test_metadata_service_self_tests_test_get( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + metadata_service = metadata_services_fixture.create_nyt_integration() + metadata_service.self_test_results = {"test": "results"} + + # Make sure that HasSelfTest.prior_test_results() was called and that + # it is in the response's self tests object. + with flask_app_fixture.test_request_context("/"): + response = metadata_services_fixture.controller.process_metadata_service_self_tests( + metadata_service.id + ) + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + response_metadata_service = response_data.get("self_test_results", {}) + + assert response_metadata_service.get("id") == metadata_service.id + assert response_metadata_service.get("name") == metadata_service.name + assert ( + response_metadata_service.get("protocol") + == metadata_services_fixture.nyt_protocol + ) + assert metadata_service.goal is not None + assert response_metadata_service.get("goal") == metadata_service.goal.value + assert response_metadata_service.get("self_test_results") == { + "test": "results" + } + + def test_metadata_service_self_tests_test_get_not_supported( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + ): + metadata_service = metadata_services_fixture.create_novelist_integration() + with flask_app_fixture.test_request_context("/"): + response = metadata_services_fixture.controller.process_metadata_service_self_tests( + metadata_service.id + ) + + assert isinstance(response, Response) + assert response.status_code == 200 + response_data = response.json + assert isinstance(response_data, dict) + response_metadata_service = response_data.get("self_test_results", {}) + assert response_metadata_service.get("id") == metadata_service.id + assert response_metadata_service.get("name") == metadata_service.name + assert response_metadata_service.get("protocol") == metadata_service.protocol + assert metadata_service.goal is not None + assert response_metadata_service.get("goal") == metadata_service.goal.value + assert response_metadata_service.get("self_test_results") == { + "exception": "Self tests are not supported for this integration.", + "disabled": True, + } + + def test_metadata_service_self_tests_post( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + db: DatabaseTransactionFixture, + ): + metadata_service = metadata_services_fixture.create_nyt_integration() + mock_run_self_tests = create_autospec( + NYTBestSellerAPI.run_self_tests, return_value=(dict(test="results"), None) + ) + monkeypatch.setattr(NYTBestSellerAPI, "run_self_tests", mock_run_self_tests) + + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_metadata_service_self_tests( + metadata_service.id + ) + assert isinstance(response, Response) + assert response.status_code == 200 + assert "Successfully ran new self tests" == response.get_data(as_text=True) + + mock_run_self_tests.assert_called_once_with( + db.session, NYTBestSellerAPI, db.session, {"password": "xyz"} + ) + + def test_metadata_service_self_tests_post_not_supported( + self, + metadata_services_fixture: MetadataServicesFixture, + flask_app_fixture: FlaskAppFixture, + monkeypatch: MonkeyPatch, + ): + metadata_service = metadata_services_fixture.create_novelist_integration() + controller = metadata_services_fixture.controller + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_metadata_service_self_tests( + metadata_service.id + ) + assert isinstance(response, ProblemDetail) + assert response == FAILED_TO_RUN_SELF_TESTS diff --git a/tests/api/admin/controller/test_patron_auth.py b/tests/api/admin/controller/test_patron_auth.py index 6d03b7a03..e80fd7bfa 100644 --- a/tests/api/admin/controller/test_patron_auth.py +++ b/tests/api/admin/controller/test_patron_auth.py @@ -1,8 +1,7 @@ from __future__ import annotations import json -from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from unittest.mock import MagicMock import flask @@ -11,13 +10,16 @@ from flask import Response from werkzeug.datastructures import ImmutableMultiDict +from api.admin.controller.patron_auth_services import PatronAuthServicesController from api.admin.exceptions import AdminNotAuthorized from api.admin.problem_details import ( CANNOT_CHANGE_PROTOCOL, + FAILED_TO_RUN_SELF_TESTS, INCOMPLETE_CONFIGURATION, INTEGRATION_NAME_ALREADY_IN_USE, INVALID_CONFIGURATION_OPTION, INVALID_LIBRARY_IDENTIFIER_RESTRICTION_REGULAR_EXPRESSION, + MISSING_IDENTIFIER, MISSING_SERVICE, MISSING_SERVICE_NAME, MULTIPLE_BASIC_AUTH_SERVICES, @@ -35,54 +37,21 @@ from api.simple_authentication import SimpleAuthenticationProvider from api.sip import SIP2AuthenticationProvider from core.integration.goals import Goals -from core.model import AdminRole, Library, get_one +from core.model import Library, get_one from core.model.integration import IntegrationConfiguration from core.problem_details import INVALID_INPUT +from core.selftest import HasSelfTests from core.util.problem_detail import ProblemDetail +from tests.fixtures.flask import FlaskAppFixture if TYPE_CHECKING: - from tests.fixtures.api_admin import SettingsControllerFixture from tests.fixtures.authenticator import ( MilleniumAuthIntegrationFixture, SamlAuthIntegrationFixture, SimpleAuthIntegrationFixture, Sip2AuthIntegrationFixture, ) - from tests.fixtures.database import ( - DatabaseTransactionFixture, - IntegrationLibraryConfigurationFixture, - ) - - -@pytest.fixture -def get_response( - settings_ctrl_fixture: SettingsControllerFixture, -) -> Callable[[], dict[str, Any] | ProblemDetail]: - def get() -> dict[str, Any] | ProblemDetail: - with settings_ctrl_fixture.request_context_with_admin("/"): - response_obj = ( - settings_ctrl_fixture.manager.admin_patron_auth_services_controller.process_patron_auth_services() - ) - if isinstance(response_obj, ProblemDetail): - return response_obj - return json.loads(response_obj.response[0]) # type: ignore[index] - - return get - - -@pytest.fixture -def post_response( - settings_ctrl_fixture: SettingsControllerFixture, -) -> Callable[..., Response | ProblemDetail]: - def post(form: ImmutableMultiDict[str, str]) -> Response | ProblemDetail: - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = form - response = ( - settings_ctrl_fixture.manager.admin_patron_auth_services_controller.process_patron_auth_services() - ) - return response - - return post + from tests.fixtures.database import DatabaseTransactionFixture @pytest.fixture @@ -96,43 +65,56 @@ def common_args() -> list[tuple[str, str]]: ] +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> PatronAuthServicesController: + mock_manager = MagicMock() + mock_manager._db = db.session + return PatronAuthServicesController(mock_manager) + + class TestPatronAuth: def test_patron_auth_services_get_with_no_services( self, - settings_ctrl_fixture: SettingsControllerFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - response = get_response() - assert isinstance(response, dict) - assert response.get("patron_auth_services") == [] - protocols = response.get("protocols") + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + assert response_data.get("patron_auth_services") == [] + protocols = response_data.get("protocols") assert isinstance(protocols, list) - assert 8 == len(protocols) + assert 7 == len(protocols) assert "settings" in protocols[0] assert "library_settings" in protocols[0] - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() - pytest.raises( - AdminNotAuthorized, - get_response, - ) + # Test request without admin set + with flask_app_fixture.test_request_context("/"): + pytest.raises( + AdminNotAuthorized, + controller.process_patron_auth_services, + ) def test_patron_auth_services_get_with_simple_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, - create_integration_library_configuration: IntegrationLibraryConfigurationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_simple_auth_integration( test_identifier="user", test_password="pass" ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert auth_service.name == service.get("name") @@ -141,34 +123,25 @@ def test_patron_auth_services_get_with_simple_auth_service( assert "pass" == service.get("settings").get("test_password") assert [] == service.get("libraries") - create_integration_library_configuration(db.default_library(), auth_service) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + auth_service.libraries += [db.default_library()] + + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert "user" == service.get("settings").get("test_identifier") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) - - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) - - [library] = service.get("libraries", []) - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_get_with_millenium_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_millenium_auth_integration: MilleniumAuthIntegrationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_millenium_auth_integration( db.default_library(), @@ -178,9 +151,12 @@ def test_patron_auth_services_get_with_millenium_auth_service( password_regular_expression="p*", ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert MilleniumPatronAPI.__module__ == service.get("protocol") @@ -189,17 +165,14 @@ def test_patron_auth_services_get_with_millenium_auth_service( assert "u*" == service.get("settings").get("identifier_regular_expression") assert "p*" == service.get("settings").get("password_regular_expression") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_get_with_sip2_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_sip2_auth_integration: Sip2AuthIntegrationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_sip2_auth_integration( db.default_library(), @@ -211,9 +184,12 @@ def test_patron_auth_services_get_with_sip2_auth_service( field_separator=",", ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert SIP2AuthenticationProvider.__module__ == service.get("protocol") @@ -224,290 +200,314 @@ def test_patron_auth_services_get_with_sip2_auth_service( assert "5" == service.get("settings").get("location_code") assert "," == service.get("settings").get("field_separator") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_get_with_saml_auth_service( self, - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, create_saml_auth_integration: SamlAuthIntegrationFixture, - get_response: Callable[[], dict[str, Any] | ProblemDetail], ): auth_service, _ = create_saml_auth_integration( db.default_library(), ) - response = get_response() - assert isinstance(response, dict) - [service] = response.get("patron_auth_services", []) + with flask_app_fixture.test_request_context_system_admin("/"): + response = controller.process_patron_auth_services() + assert isinstance(response, Response) + response_data = response.json + assert isinstance(response_data, dict) + [service] = response_data.get("patron_auth_services", []) assert auth_service.id == service.get("id") assert SAMLWebSSOAuthenticationProvider.__module__ == service.get("protocol") [library] = service.get("libraries") - assert ( - settings_ctrl_fixture.ctrl.db.default_library().short_name - == library.get("short_name") - ) + assert db.default_library().short_name == library.get("short_name") def test_patron_auth_services_post_unknown_protocol( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - form = ImmutableMultiDict( - [ - ("protocol", "Unknown"), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", "Unknown"), + ] + ) + response = controller.process_patron_auth_services() assert response == UNKNOWN_PROTOCOL def test_patron_auth_services_post_no_protocol( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - form: ImmutableMultiDict[str, str] = ImmutableMultiDict([]) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict([]) + response = controller.process_patron_auth_services() assert response == NO_PROTOCOL_FOR_NEW_SERVICE def test_patron_auth_services_post_missing_service( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - form = ImmutableMultiDict( - [ - ("protocol", SimpleAuthenticationProvider.__module__), - ("id", "123"), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", SimpleAuthenticationProvider.__module__), + ("id", "123"), + ] + ) + response = controller.process_patron_auth_services() assert response == MISSING_SERVICE def test_patron_auth_services_post_cannot_change_protocol( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, ): auth_service, _ = create_simple_auth_integration() - form = ImmutableMultiDict( - [ - ("id", str(auth_service.id)), - ("protocol", SIP2AuthenticationProvider.__module__), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("id", str(auth_service.id)), + ("protocol", SIP2AuthenticationProvider.__module__), + ] + ) + response = controller.process_patron_auth_services() assert response == CANNOT_CHANGE_PROTOCOL def test_patron_auth_services_post_name_in_use( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, ): auth_service, _ = create_simple_auth_integration() - form = ImmutableMultiDict( - [ - ("name", auth_service.name), - ("protocol", SIP2AuthenticationProvider.__module__), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", str(auth_service.name)), + ("protocol", SIP2AuthenticationProvider.__module__), + ] + ) + response = controller.process_patron_auth_services() assert response == INTEGRATION_NAME_ALREADY_IN_USE def test_patron_auth_services_post_invalid_configuration( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_millenium_auth_integration: MilleniumAuthIntegrationFixture, common_args: list[tuple[str, str]], ): auth_service, _ = create_millenium_auth_integration() - form = ImmutableMultiDict( - [ - ("name", "some auth name"), - ("id", str(auth_service.id)), - ("protocol", MilleniumPatronAPI.__module__), - ("url", "http://url"), - ("authentication_mode", "Invalid mode"), - ("verify_certificate", "true"), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "some auth name"), + ("id", str(auth_service.id)), + ("protocol", MilleniumPatronAPI.__module__), + ("url", "http://url"), + ("authentication_mode", "Invalid mode"), + ("verify_certificate", "true"), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == INVALID_CONFIGURATION_OPTION.uri def test_patron_auth_services_post_incomplete_configuration( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, common_args: list[tuple[str, str]], ): auth_service, _ = create_simple_auth_integration() - form = ImmutableMultiDict( - [ - ("id", str(auth_service.id)), - ("protocol", SimpleAuthenticationProvider.__module__), - ] - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("id", str(auth_service.id)), + ("protocol", SimpleAuthenticationProvider.__module__), + ] + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == INCOMPLETE_CONFIGURATION.uri def test_patron_auth_services_post_missing_patron_auth_name( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("protocol", SimpleAuthenticationProvider.__module__), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", SimpleAuthenticationProvider.__module__), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response == MISSING_SERVICE_NAME def test_patron_auth_services_post_no_such_library( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ("libraries", json.dumps([{"short_name": "not-a-library"}])), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ("libraries", json.dumps([{"short_name": "not-a-library"}])), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == NO_SUCH_LIBRARY.uri def test_patron_auth_services_post_missing_short_name( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ("libraries", json.dumps([{}])), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ("libraries", json.dumps([{}])), + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == INVALID_INPUT.uri assert response.detail == "Invalid library settings, missing short_name." def test_patron_auth_services_post_missing_patron_auth_multiple_basic( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, default_library: Library, common_args: list[tuple[str, str]], ): auth_service, _ = create_simple_auth_integration(default_library) - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": default_library.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, - "library_identifier_field": "barcode", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": default_library.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, + "library_identifier_field": "barcode", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response.uri == MULTIPLE_BASIC_AUTH_SERVICES.uri def test_patron_auth_services_post_invalid_library_identifier_restriction_regex( self, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, default_library: Library, common_args: list[tuple[str, str]], ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": default_library.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, - "library_identifier_field": "barcode", - "library_identifier_restriction_criteria": "(invalid re", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": default_library.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, + "library_identifier_field": "barcode", + "library_identifier_restriction_criteria": "(invalid re", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() assert isinstance(response, ProblemDetail) assert response == INVALID_LIBRARY_IDENTIFIER_RESTRICTION_REGULAR_EXPRESSION def test_patron_auth_services_post_not_authorized( self, common_args: list[tuple[str, str]], - settings_ctrl_fixture: SettingsControllerFixture, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, ): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - form = ImmutableMultiDict( - [ - ("protocol", SimpleAuthenticationProvider.__module__), - ] - + common_args - ) - pytest.raises(AdminNotAuthorized, post_response, form) + with flask_app_fixture.test_request_context("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("protocol", SimpleAuthenticationProvider.__module__), + ] + + common_args + ) + pytest.raises(AdminNotAuthorized, controller.process_patron_auth_services) def test_patron_auth_services_post_create( self, common_args: list[tuple[str, str]], default_library: Library, - post_response: Callable[..., Response | ProblemDetail], + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, db: DatabaseTransactionFixture, ): - form = ImmutableMultiDict( - [ - ("name", "testing auth name"), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": default_library.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, - "library_identifier_field": "barcode", - "library_identifier_restriction_criteria": "^1234", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth name"), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": default_library.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.REGEX.value, + "library_identifier_field": "barcode", + "library_identifier_restriction_criteria": "^1234", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() + assert isinstance(response, Response) assert response.status_code == 201 auth_service = get_one( @@ -529,17 +529,19 @@ def test_patron_auth_services_post_create( == "^1234" ) - form = ImmutableMultiDict( - [ - ("name", "testing auth 2 name"), - ("protocol", MilleniumPatronAPI.__module__), - ("url", "https://url.com"), - ("verify_certificate", "false"), - ("authentication_mode", "pin"), - ] - + common_args - ) - response = post_response(form) + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("name", "testing auth 2 name"), + ("protocol", MilleniumPatronAPI.__module__), + ("url", "https://url.com"), + ("verify_certificate", "false"), + ("authentication_mode", "pin"), + ] + + common_args + ) + response = controller.process_patron_auth_services() + assert isinstance(response, Response) assert response.status_code == 201 auth_service2 = get_one( @@ -562,9 +564,9 @@ def test_patron_auth_services_post_create( def test_patron_auth_services_post_edit( self, - post_response: Callable[..., Response | ProblemDetail], common_args: list[tuple[str, str]], - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, db: DatabaseTransactionFixture, monkeypatch: MonkeyPatch, @@ -584,29 +586,31 @@ def test_patron_auth_services_post_edit( "old_password", ) - form = ImmutableMultiDict( - [ - ("id", str(auth_service.id)), - ("protocol", SimpleAuthenticationProvider.__module__), - ( - "libraries", - json.dumps( - [ - { - "short_name": l2.short_name, - "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, - "library_identifier_field": "barcode", - } - ] + with flask_app_fixture.test_request_context_system_admin("/", method="POST"): + flask.request.form = ImmutableMultiDict( + [ + ("id", str(auth_service.id)), + ("protocol", SimpleAuthenticationProvider.__module__), + ( + "libraries", + json.dumps( + [ + { + "short_name": l2.short_name, + "library_identifier_restriction_type": LibraryIdentifierRestriction.NONE.value, + "library_identifier_field": "barcode", + } + ] + ), ), - ), - ] - + common_args - ) - response = post_response(form) + ] + + common_args + ) + response = controller.process_patron_auth_services() + assert isinstance(response, Response) assert response.status_code == 200 - assert auth_service.id == int(response.response[0]) # type: ignore[index] + assert auth_service.id == int(response.get_data(as_text=True)) assert SimpleAuthenticationProvider.__module__ == auth_service.protocol assert isinstance(auth_service.settings_dict, dict) settings = SimpleAuthenticationProvider.settings_load(auth_service) @@ -628,12 +632,11 @@ def test_patron_auth_services_post_edit( def test_patron_auth_service_delete( self, common_args: list[tuple[str, str]], - settings_ctrl_fixture: SettingsControllerFixture, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, create_simple_auth_integration: SimpleAuthIntegrationFixture, + db: DatabaseTransactionFixture, ): - controller = settings_ctrl_fixture.manager.admin_patron_auth_services_controller - db = settings_ctrl_fixture.ctrl.db - l1 = db.library("Library 1", "L1") auth_service, _ = create_simple_auth_integration( l1, @@ -641,22 +644,167 @@ def test_patron_auth_service_delete( "old_password", ) - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context("/", method="DELETE"): pytest.raises( AdminNotAuthorized, controller.process_delete, auth_service.id, ) - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) + with flask_app_fixture.test_request_context_system_admin("/", method="DELETE"): assert auth_service.id is not None response = controller.process_delete(auth_service.id) assert response.status_code == 200 service = get_one( - settings_ctrl_fixture.ctrl.db.session, + db.session, IntegrationConfiguration, id=auth_service.id, ) assert service is None + + def test_patron_auth_self_tests_with_no_identifier( + self, controller: PatronAuthServicesController + ): + response = controller.process_patron_auth_service_self_tests(None) + assert isinstance(response, ProblemDetail) + assert response.title == MISSING_IDENTIFIER.title + assert response.detail == MISSING_IDENTIFIER.detail + assert response.status_code == 400 + + def test_patron_auth_self_tests_with_no_auth_service_found( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + ): + with flask_app_fixture.test_request_context("/"): + response = controller.process_patron_auth_service_self_tests(-1) + assert isinstance(response, ProblemDetail) + assert response == MISSING_SERVICE + assert response.status_code == 404 + + def test_patron_auth_self_tests_get_with_no_libraries( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + ): + auth_service, _ = create_simple_auth_integration() + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response_obj, Response) + response = response_obj.json + assert isinstance(response, dict) + results = response.get("self_test_results", {}).get("self_test_results") + assert results.get("disabled") is True + assert ( + results.get("exception") + == "You must associate this service with at least one library before you can run self tests for it." + ) + + def test_patron_auth_self_tests_test_get_no_results( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + default_library: Library, + ): + auth_service, _ = create_simple_auth_integration(library=default_library) + + # Make sure that we return the correct response when there are no results + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response_obj, Response) + response = response_obj.json + assert isinstance(response, dict) + response_auth_service = response.get("self_test_results", {}) + + assert response_auth_service.get("name") == auth_service.name + assert response_auth_service.get("protocol") == auth_service.protocol + assert response_auth_service.get("id") == auth_service.id + assert auth_service.goal is not None + assert response_auth_service.get("goal") == auth_service.goal.value + assert response_auth_service.get("self_test_results") == "No results yet" + + def test_patron_auth_self_tests_test_get( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + default_library: Library, + ): + expected_results = dict( + duration=0.9, + start="2018-08-08T16:04:05Z", + end="2018-08-08T16:05:05Z", + results=[], + ) + auth_service, _ = create_simple_auth_integration(library=default_library) + auth_service.self_test_results = expected_results + + # Make sure that HasSelfTest.prior_test_results() was called and that + # it is in the response's self tests object. + with flask_app_fixture.test_request_context("/"): + response_obj = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response_obj, Response) + response = response_obj.json + assert isinstance(response, dict) + response_auth_service = response.get("self_test_results", {}) + + assert response_auth_service.get("name") == auth_service.name + assert response_auth_service.get("protocol") == auth_service.protocol + assert response_auth_service.get("id") == auth_service.id + assert auth_service.goal is not None + assert response_auth_service.get("goal") == auth_service.goal.value + assert response_auth_service.get("self_test_results") == expected_results + + def test_patron_auth_self_tests_post_with_no_libraries( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + ): + auth_service, _ = create_simple_auth_integration() + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_patron_auth_service_self_tests( + auth_service.id, + ) + assert isinstance(response, ProblemDetail) + assert response.title == FAILED_TO_RUN_SELF_TESTS.title + assert response.detail is not None + assert "Failed to run self tests" in response.detail + assert response.status_code == 400 + + def test_patron_auth_self_tests_test_post( + self, + controller: PatronAuthServicesController, + flask_app_fixture: FlaskAppFixture, + create_simple_auth_integration: SimpleAuthIntegrationFixture, + monkeypatch: MonkeyPatch, + db: DatabaseTransactionFixture, + ): + expected_results = ("value", "results") + mock = MagicMock(return_value=expected_results) + monkeypatch.setattr(HasSelfTests, "run_self_tests", mock) + library = db.default_library() + auth_service, _ = create_simple_auth_integration(library=library) + + with flask_app_fixture.test_request_context("/", method="POST"): + response = controller.process_patron_auth_service_self_tests( + auth_service.id + ) + assert isinstance(response, Response) + assert response.status == "200 OK" + assert "Successfully ran new self tests" == response.get_data(as_text=True) + + assert mock.call_count == 1 + assert mock.call_args.args[0] == db.session + assert mock.call_args.args[1] is None + assert mock.call_args.args[2] == library.id + assert mock.call_args.args[3] == auth_service.id diff --git a/tests/api/admin/controller/test_patron_auth_self_tests.py b/tests/api/admin/controller/test_patron_auth_self_tests.py deleted file mode 100644 index 12816805f..000000000 --- a/tests/api/admin/controller/test_patron_auth_self_tests.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -import pytest -from flask import Response - -from api.admin.controller.patron_auth_service_self_tests import ( - PatronAuthServiceSelfTestsController, -) -from api.admin.problem_details import ( - FAILED_TO_RUN_SELF_TESTS, - MISSING_IDENTIFIER, - MISSING_SERVICE, -) -from core.model import Library -from core.selftest import HasSelfTestsIntegrationConfiguration -from core.util.problem_detail import ProblemDetail - -if TYPE_CHECKING: - from _pytest.monkeypatch import MonkeyPatch - from flask.ctx import RequestContext - - from tests.fixtures.authenticator import SimpleAuthIntegrationFixture - from tests.fixtures.database import DatabaseTransactionFixture - - -@pytest.fixture -def controller(db: DatabaseTransactionFixture) -> PatronAuthServiceSelfTestsController: - return PatronAuthServiceSelfTestsController(db.session) - - -class TestPatronAuthSelfTests: - def test_patron_auth_self_tests_with_no_identifier( - self, controller: PatronAuthServiceSelfTestsController - ): - response = controller.process_patron_auth_service_self_tests(None) - assert isinstance(response, ProblemDetail) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_patron_auth_self_tests_with_no_auth_service_found( - self, - controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, - ): - response = controller.process_patron_auth_service_self_tests(-1) - assert isinstance(response, ProblemDetail) - assert response == MISSING_SERVICE - assert response.status_code == 404 - - def test_patron_auth_self_tests_get_with_no_libraries( - self, - controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - ): - auth_service, _ = create_simple_auth_integration() - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) - assert isinstance(response_obj, Response) - response = json.loads(response_obj.response[0]) # type: ignore[index] - results = response.get("self_test_results", {}).get("self_test_results") - assert results.get("disabled") is True - assert ( - results.get("exception") - == "You must associate this service with at least one library before you can run self tests for it." - ) - - def test_patron_auth_self_tests_test_get_no_results( - self, - controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - default_library: Library, - ): - auth_service, _ = create_simple_auth_integration(library=default_library) - - # Make sure that we return the correct response when there are no results - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) - assert isinstance(response_obj, Response) - response = json.loads(response_obj.response[0]) # type: ignore[index] - response_auth_service = response.get("self_test_results", {}) - - assert response_auth_service.get("name") == auth_service.name - assert response_auth_service.get("protocol") == auth_service.protocol - assert response_auth_service.get("id") == auth_service.id - assert auth_service.goal is not None - assert response_auth_service.get("goal") == auth_service.goal.value - assert response_auth_service.get("self_test_results") == "No results yet" - - def test_patron_auth_self_tests_test_get( - self, - controller: PatronAuthServiceSelfTestsController, - get_request_context: RequestContext, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - monkeypatch: MonkeyPatch, - default_library: Library, - ): - expected_results = dict( - duration=0.9, - start="2018-08-08T16:04:05Z", - end="2018-08-08T16:05:05Z", - results=[], - ) - mock = MagicMock(return_value=expected_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "load_self_test_results", mock - ) - auth_service, _ = create_simple_auth_integration(library=default_library) - - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's self tests object. - response_obj = controller.process_patron_auth_service_self_tests( - auth_service.id - ) - assert isinstance(response_obj, Response) - response = json.loads(response_obj.response[0]) # type: ignore[index] - response_auth_service = response.get("self_test_results", {}) - - assert response_auth_service.get("name") == auth_service.name - assert response_auth_service.get("protocol") == auth_service.protocol - assert response_auth_service.get("id") == auth_service.id - assert auth_service.goal is not None - assert response_auth_service.get("goal") == auth_service.goal.value - assert response_auth_service.get("self_test_results") == expected_results - mock.assert_called_once_with(auth_service) - - def test_patron_auth_self_tests_post_with_no_libraries( - self, - controller: PatronAuthServiceSelfTestsController, - post_request_context: RequestContext, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - ): - auth_service, _ = create_simple_auth_integration() - response = controller.process_patron_auth_service_self_tests(auth_service.id) - assert isinstance(response, ProblemDetail) - assert response.title == FAILED_TO_RUN_SELF_TESTS.title - assert response.detail is not None - assert "Failed to run self tests" in response.detail - assert response.status_code == 400 - - def test_patron_auth_self_tests_test_post( - self, - controller: PatronAuthServiceSelfTestsController, - post_request_context: RequestContext, - create_simple_auth_integration: SimpleAuthIntegrationFixture, - monkeypatch: MonkeyPatch, - db: DatabaseTransactionFixture, - ): - expected_results = ("value", "results") - mock = MagicMock(return_value=expected_results) - monkeypatch.setattr( - HasSelfTestsIntegrationConfiguration, "run_self_tests", mock - ) - library = db.default_library() - auth_service, _ = create_simple_auth_integration(library=library) - - response = controller.process_patron_auth_service_self_tests(auth_service.id) - assert isinstance(response, Response) - assert response.status == "200 OK" - assert "Successfully ran new self tests" == response.get_data(as_text=True) - - assert mock.call_count == 1 - assert mock.call_args.args[0] == db.session - assert mock.call_args.args[1] is None - assert mock.call_args.args[2] == library.id - assert mock.call_args.args[3] == auth_service.id diff --git a/tests/api/admin/controller/test_search_service_self_tests.py b/tests/api/admin/controller/test_search_service_self_tests.py deleted file mode 100644 index f32b61575..000000000 --- a/tests/api/admin/controller/test_search_service_self_tests.py +++ /dev/null @@ -1,93 +0,0 @@ -from api.admin.problem_details import * -from core.model import ExternalIntegration, create -from core.selftest import HasSelfTests - - -class TestSearchServiceSelfTests: - def test_search_service_self_tests_with_no_identifier(self, settings_ctrl_fixture): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( - None - ) - assert response.title == MISSING_IDENTIFIER.title - assert response.detail == MISSING_IDENTIFIER.detail - assert response.status_code == 400 - - def test_search_service_self_tests_with_no_search_service_found( - self, settings_ctrl_fixture - ): - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( - -1 - ) - assert response == MISSING_SERVICE - assert response.status_code == 404 - - def test_search_service_self_tests_test_get(self, settings_ctrl_fixture): - old_prior_test_results = HasSelfTests.prior_test_results - HasSelfTests.prior_test_results = settings_ctrl_fixture.mock_prior_test_results - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - # Make sure that HasSelfTest.prior_test_results() was called and that - # it is in the response's self tests object. - with settings_ctrl_fixture.request_context_with_admin("/"): - response = settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.process_search_service_self_tests( - search_service.id - ) - response_search_service = response.get("self_test_results") - - assert response_search_service.get("id") == search_service.id - assert response_search_service.get("name") == search_service.name - assert ( - response_search_service.get("protocol").get("label") - == search_service.protocol - ) - assert response_search_service.get("goal") == search_service.goal - assert ( - response_search_service.get("self_test_results") - == HasSelfTests.prior_test_results() - ) - - HasSelfTests.prior_test_results = old_prior_test_results - - def test_search_service_self_tests_post(self, settings_ctrl_fixture): - old_run_self_tests = HasSelfTests.run_self_tests - HasSelfTests.run_self_tests = settings_ctrl_fixture.mock_run_self_tests - - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - m = ( - settings_ctrl_fixture.manager.admin_search_service_self_tests_controller.self_tests_process_post - ) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - response = m(search_service.id) - assert response._status == "200 OK" - assert "Successfully ran new self tests" == response.get_data(as_text=True) - - positional, keyword = settings_ctrl_fixture.run_self_tests_called_with - # run_self_tests was called with positional arguments: - # * The database connection - # * The method to call to instantiate a HasSelfTests implementation - # (None -- this means to use the default ExternalSearchIndex - # constructor.) - # * The database connection again (to be passed into - # the ExternalSearchIndex constructor). - assert ( - settings_ctrl_fixture.ctrl.db.session, - None, - settings_ctrl_fixture.ctrl.db.session, - ) == positional - - # run_self_tests was not called with any keyword arguments. - assert {} == keyword - - # Undo the mock. - HasSelfTests.run_self_tests = old_run_self_tests diff --git a/tests/api/admin/controller/test_search_services.py b/tests/api/admin/controller/test_search_services.py deleted file mode 100644 index ebd470ea0..000000000 --- a/tests/api/admin/controller/test_search_services.py +++ /dev/null @@ -1,301 +0,0 @@ -import flask -import pytest -from werkzeug.datastructures import MultiDict - -from api.admin.exceptions import AdminNotAuthorized -from api.admin.problem_details import ( - INCOMPLETE_CONFIGURATION, - INTEGRATION_NAME_ALREADY_IN_USE, - MISSING_SERVICE, - MULTIPLE_SITEWIDE_SERVICES, - NO_PROTOCOL_FOR_NEW_SERVICE, - UNKNOWN_PROTOCOL, -) -from core.external_search import ExternalSearchIndex -from core.model import AdminRole, ExternalIntegration, create, get_one - - -class TestSearchServices: - def test_search_services_get_with_no_services(self, settings_ctrl_fixture): - # Delete the search integration - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - assert response.get("search_services") == [] - protocols = response.get("protocols") - assert ExternalIntegration.OPENSEARCH in [p.get("name") for p in protocols] - assert "settings" in protocols[0] - - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - settings_ctrl_fixture.ctrl.db.session.flush() - pytest.raises( - AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_search_services_controller.process_services, - ) - - def test_search_services_get_with_one_service(self, settings_ctrl_fixture): - # Delete the pre-existing integration - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - search_service, ignore = create( - session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - search_service.url = "search url" - search_service.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value = "works-index-prefix" - search_service.setting( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY - ).value = "search-term-for-self-tests" - - with settings_ctrl_fixture.request_context_with_admin("/"): - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - [service] = response.get("search_services") - - assert search_service.id == service.get("id") - assert search_service.protocol == service.get("protocol") - assert "search url" == service.get("settings").get(ExternalIntegration.URL) - assert "works-index-prefix" == service.get("settings").get( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ) - assert "search-term-for-self-tests" == service.get("settings").get( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY - ) - - def test_search_services_post_errors(self, settings_ctrl_fixture): - controller = settings_ctrl_fixture.manager.admin_search_services_controller - - # Delete the previous integrations - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("protocol", "Unknown"), - ] - ) - response = controller.process_services() - assert response == UNKNOWN_PROTOCOL - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict([("name", "Name")]) - response = controller.process_services() - assert response == NO_PROTOCOL_FOR_NEW_SERVICE - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("id", "123"), - ] - ) - response = controller.process_services() - assert response == MISSING_SERVICE - - service, ignore = create( - session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("protocol", ExternalIntegration.OPENSEARCH), - ] - ) - response = controller.process_services() - assert response.uri == MULTIPLE_SITEWIDE_SERVICES.uri - - session.delete(service) - service, ignore = create( - session, - ExternalIntegration, - protocol="test", - goal="test", - name="name", - ) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", service.name), - ("protocol", ExternalIntegration.OPENSEARCH), - ] - ) - response = controller.process_services() - assert response == INTEGRATION_NAME_ALREADY_IN_USE - - service, ignore = create( - session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("id", service.id), - ("protocol", ExternalIntegration.OPENSEARCH), - ] - ) - response = controller.process_services() - assert response.uri == INCOMPLETE_CONFIGURATION.uri - - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("protocol", ExternalIntegration.OPENSEARCH), - (ExternalIntegration.URL, "search url"), - (ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, "works-index-prefix"), - ] - ) - pytest.raises(AdminNotAuthorized, controller.process_services) - - def test_search_services_post_create(self, settings_ctrl_fixture): - # Delete the previous integrations - session = settings_ctrl_fixture.ctrl.db.session - integration = ExternalIntegration.lookup( - session, ExternalIntegration.OPENSEARCH, ExternalIntegration.SEARCH_GOAL - ) - session.delete(integration) - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("protocol", ExternalIntegration.OPENSEARCH), - (ExternalIntegration.URL, "http://search_url"), - (ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, "works-index-prefix"), - (ExternalSearchIndex.TEST_SEARCH_TERM_KEY, "sample-search-term"), - ] - ) - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - assert response.status_code == 201 - - service = get_one( - session, - ExternalIntegration, - goal=ExternalIntegration.SEARCH_GOAL, - ) - assert service.id == int(response.response[0]) - assert ExternalIntegration.OPENSEARCH == service.protocol - assert "http://search_url" == service.url - assert ( - "works-index-prefix" - == service.setting(ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY).value - ) - assert ( - "sample-search-term" - == service.setting(ExternalSearchIndex.TEST_SEARCH_TERM_KEY).value - ) - - def test_search_services_post_edit(self, settings_ctrl_fixture): - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - search_service.url = "search url" - search_service.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value = "works-index-prefix" - search_service.setting( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY - ).value = "sample-search-term" - - with settings_ctrl_fixture.request_context_with_admin("/", method="POST"): - flask.request.form = MultiDict( - [ - ("name", "Name"), - ("id", search_service.id), - ("protocol", ExternalIntegration.OPENSEARCH), - (ExternalIntegration.URL, "http://new_search_url"), - ( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, - "new-works-index-prefix", - ), - ( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY, - "new-sample-search-term", - ), - ] - ) - response = ( - settings_ctrl_fixture.manager.admin_search_services_controller.process_services() - ) - assert response.status_code == 200 - - assert search_service.id == int(response.response[0]) - assert ExternalIntegration.OPENSEARCH == search_service.protocol - assert "http://new_search_url" == search_service.url - assert ( - "new-works-index-prefix" - == search_service.setting(ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY).value - ) - assert ( - "new-sample-search-term" - == search_service.setting(ExternalSearchIndex.TEST_SEARCH_TERM_KEY).value - ) - - def test_search_service_delete(self, settings_ctrl_fixture): - search_service, ignore = create( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - protocol=ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - ) - search_service.url = "search url" - search_service.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value = "works-index-prefix" - - with settings_ctrl_fixture.request_context_with_admin("/", method="DELETE"): - settings_ctrl_fixture.admin.remove_role(AdminRole.SYSTEM_ADMIN) - pytest.raises( - AdminNotAuthorized, - settings_ctrl_fixture.manager.admin_search_services_controller.process_delete, - search_service.id, - ) - - settings_ctrl_fixture.admin.add_role(AdminRole.SYSTEM_ADMIN) - response = settings_ctrl_fixture.manager.admin_search_services_controller.process_delete( - search_service.id - ) - assert response.status_code == 200 - - service = get_one( - settings_ctrl_fixture.ctrl.db.session, - ExternalIntegration, - id=search_service.id, - ) - assert None == service diff --git a/tests/api/admin/controller/test_settings.py b/tests/api/admin/controller/test_settings.py index 12f380ab6..479d72187 100644 --- a/tests/api/admin/controller/test_settings.py +++ b/tests/api/admin/controller/test_settings.py @@ -71,9 +71,7 @@ def test_get_integration_info( self, settings_ctrl_fixture: SettingsControllerFixture ): """Test the _get_integration_info helper method.""" - m = ( - settings_ctrl_fixture.manager.admin_settings_controller._get_integration_info - ) + m = settings_ctrl_fixture.controller._get_integration_info # Test the case where there are integrations in the database # with the given goal, but none of them match the @@ -87,7 +85,7 @@ def test_get_integration_info( def test_create_integration(self, settings_ctrl_fixture: SettingsControllerFixture): """Test the _create_integration helper method.""" - m = settings_ctrl_fixture.manager.admin_settings_controller._create_integration + m = settings_ctrl_fixture.controller._create_integration protocol_definitions = [ dict(name="allow many"), @@ -131,7 +129,7 @@ def test_create_integration(self, settings_ctrl_fixture: SettingsControllerFixtu def test_check_url_unique(self, settings_ctrl_fixture: SettingsControllerFixture): # Verify our ability to catch duplicate integrations for a # given URL. - m = settings_ctrl_fixture.manager.admin_settings_controller.check_url_unique + m = settings_ctrl_fixture.controller.check_url_unique # Here's an ExternalIntegration. original = settings_ctrl_fixture.ctrl.db.external_integration( @@ -215,9 +213,7 @@ def m(url): def test__get_protocol_class( self, settings_ctrl_fixture: SettingsControllerFixture ): - _get_protocol_class = ( - settings_ctrl_fixture.manager.admin_settings_controller._get_settings_class - ) + _get_protocol_class = settings_ctrl_fixture.controller._get_settings_class registry = IntegrationRegistry[Any](Goals.LICENSE_GOAL) class P1Settings(BaseSettings): @@ -261,7 +257,7 @@ def test__set_configuration_library( db = settings_ctrl_fixture.ctrl.db config = db.default_collection().integration_configuration _set_configuration_library = ( - settings_ctrl_fixture.manager.admin_settings_controller._set_configuration_library + settings_ctrl_fixture.controller._set_configuration_library ) library = db.library(short_name="short-name") diff --git a/tests/api/admin/controller/test_sitewide_services.py b/tests/api/admin/controller/test_sitewide_services.py deleted file mode 100644 index dfa3f8432..000000000 --- a/tests/api/admin/controller/test_sitewide_services.py +++ /dev/null @@ -1,34 +0,0 @@ -from api.admin.controller.sitewide_services import * -from core.model import ExternalIntegration - - -class TestSitewideServices: - def test_sitewide_service_management(self, settings_ctrl_fixture): - # The configuration of search and logging collections is delegated to - # the _manage_sitewide_service and _delete_integration methods. - # - # Search collections are more comprehensively tested in test_search_services. - - EI = ExternalIntegration - - class MockSearch(SearchServicesController): - def _manage_sitewide_service(self, *args): - self.manage_called_with = args - - def _delete_integration(self, *args): - self.delete_called_with = args - - controller = MockSearch(settings_ctrl_fixture.manager) - - with settings_ctrl_fixture.request_context_with_admin("/"): - controller.process_services() - goal, apis, key_name, problem = controller.manage_called_with - assert EI.SEARCH_GOAL == goal - assert ExternalSearchIndex in apis - assert "search_services" == key_name - assert "new search service" in problem - - with settings_ctrl_fixture.request_context_with_admin("/"): - id = object() - controller.process_delete(id) - assert (id, EI.SEARCH_GOAL) == controller.delete_called_with diff --git a/tests/api/admin/controller/test_work_editor.py b/tests/api/admin/controller/test_work_editor.py index fbe7b8c62..4c6605a36 100644 --- a/tests/api/admin/controller/test_work_editor.py +++ b/tests/api/admin/controller/test_work_editor.py @@ -1,4 +1,5 @@ import json +from collections.abc import Generator import feedparser import flask @@ -64,8 +65,12 @@ def __init__(self, controller_fixture: ControllerFixture): @pytest.fixture(scope="function") -def work_fixture(controller_fixture: ControllerFixture) -> WorkFixture: - return WorkFixture(controller_fixture) +def work_fixture( + controller_fixture: ControllerFixture, +) -> Generator[WorkFixture, None, None]: + fixture = WorkFixture(controller_fixture) + with fixture.ctrl.wired_container(): + yield fixture class TestWorkController: diff --git a/tests/api/admin/test_routes.py b/tests/api/admin/test_routes.py index 0ae8ffde5..d5f3cd045 100644 --- a/tests/api/admin/test_routes.py +++ b/tests/api/admin/test_routes.py @@ -1,7 +1,7 @@ import logging from collections.abc import Generator -from pathlib import Path from typing import Any +from unittest.mock import MagicMock import flask import pytest @@ -80,7 +80,7 @@ def __init__( self.controller_fixture = controller_fixture self.setup_circulation_manager = False if not self.REAL_CIRCULATION_MANAGER: - circ_manager = MockCirculationManager(self.db.session) + circ_manager = MockCirculationManager(self.db.session, MagicMock()) setup_admin_controllers(circ_manager) self.REAL_CIRCULATION_MANAGER = circ_manager @@ -494,15 +494,6 @@ def test_process_post(self, fixture: AdminRouteFixture): ) fixture.assert_supported_methods(url, "DELETE") - -class TestAdminCollectionSelfTests: - CONTROLLER_NAME = "admin_collection_self_tests_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - def test_process_collection_self_tests(self, fixture: AdminRouteFixture): url = "/admin/collection_self_tests/" fixture.assert_authenticated_request_calls( @@ -555,15 +546,6 @@ def test_process_delete(self, fixture: AdminRouteFixture): ) fixture.assert_supported_methods(url, "DELETE") - -class TestAdminPatronAuthServicesSelfTests: - CONTROLLER_NAME = "admin_patron_auth_service_self_tests_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - def test_process_patron_auth_service_self_tests(self, fixture: AdminRouteFixture): url = "/admin/patron_auth_service_self_tests/" fixture.assert_authenticated_request_calls( @@ -620,45 +602,6 @@ def test_process_delete(self, fixture: AdminRouteFixture): fixture.assert_supported_methods(url, "DELETE") -class TestAdminSearchServices: - CONTROLLER_NAME = "admin_search_services_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - - def test_process_services(self, fixture: AdminRouteFixture): - url = "/admin/search_services" - fixture.assert_authenticated_request_calls( - url, fixture.controller.process_services # type: ignore - ) - fixture.assert_supported_methods(url, "GET", "POST") - - def test_process_delete(self, fixture: AdminRouteFixture): - url = "/admin/search_service/" - fixture.assert_authenticated_request_calls( - url, fixture.controller.process_delete, "", http_method="DELETE" # type: ignore - ) - fixture.assert_supported_methods(url, "DELETE") - - -class TestAdminSearchServicesSelfTests: - CONTROLLER_NAME = "admin_search_service_self_tests_controller" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - - def test_process_search_service_self_tests(self, fixture: AdminRouteFixture): - url = "/admin/search_service_self_tests/" - fixture.assert_authenticated_request_calls( - url, fixture.controller.process_search_service_self_tests, "" # type: ignore - ) - fixture.assert_supported_methods(url, "GET", "POST") - - class TestAdminCatalogServices: CONTROLLER_NAME = "admin_catalog_services_controller" @@ -858,37 +801,6 @@ def test_admin_view(self, fixture: AdminRouteFixture): fixture.assert_request_calls(url, fixture.controller, None, None, path="a/path") -class TestAdminStatic: - CONTROLLER_NAME = "static_files" - - @pytest.fixture(scope="function") - def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: - admin_route_fixture.set_controller_name(self.CONTROLLER_NAME) - return admin_route_fixture - - def test_static_file(self, fixture: AdminRouteFixture): - # Go to the back to the root folder to get the right - # path for the static files. - root_path = Path(__file__).parent.parent.parent.parent - local_path = ( - root_path - / "api/admin/node_modules/@natlibfi/ekirjasto-circulation-admin/dist" - ) - - url = "/admin/static/circulation-admin.js" - fixture.assert_request_calls( - url, fixture.controller.static_file, str(local_path), "circulation-admin.js" # type: ignore - ) - - url = "/admin/static/circulation-admin.css" - fixture.assert_request_calls( - url, - fixture.controller.static_file, # type: ignore - str(local_path), - "circulation-admin.css", - ) - - def test_returns_json_or_response_or_problem_detail(): @routes.returns_json_or_response_or_problem_detail def mock_responses(response): diff --git a/tests/api/conftest.py b/tests/api/conftest.py index d24165c8e..10c6427c0 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -25,7 +25,6 @@ "tests.fixtures.api_overdrive_files", "tests.fixtures.api_routes", "tests.fixtures.authenticator", - "tests.fixtures.container", "tests.fixtures.csv_files", "tests.fixtures.database", "tests.fixtures.files", @@ -36,6 +35,7 @@ "tests.fixtures.opds_files", "tests.fixtures.sample_covers", "tests.fixtures.search", + "tests.fixtures.services", "tests.fixtures.time", "tests.fixtures.tls_server", "tests.fixtures.vendor_id", diff --git a/tests/api/controller/test_crawlfeed.py b/tests/api/controller/test_crawlfeed.py index a868ac55e..2345196ca 100644 --- a/tests/api/controller/test_crawlfeed.py +++ b/tests/api/controller/test_crawlfeed.py @@ -235,11 +235,6 @@ def works(self, _db, facets, pagination, *args, **kwargs): assert INVALID_INPUT.uri == response.uri assert None == self.page_called_with - # Bad search engine -> problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds._crawlable_feed(**in_kwargs) - ) - # Good pagination data -> feed_class.page() is called. sort_key = ["sort", "pagination", "key"] with circulation_fixture.request_context_with_library( @@ -297,7 +292,10 @@ def works(self, _db, facets, pagination, *args, **kwargs): # Finally, remove the mock feed class and verify that a real OPDS # feed is generated from the result of MockLane.works() del in_kwargs["feed_class"] - with circulation_fixture.request_context_with_library("/"): + with ( + circulation_fixture.request_context_with_library("/"), + circulation_fixture.wired_container(), + ): response = circulation_fixture.manager.opds_feeds._crawlable_feed( **in_kwargs ) diff --git a/tests/api/controller/test_loan.py b/tests/api/controller/test_loan.py index 110cab527..4c2891569 100644 --- a/tests/api/controller/test_loan.py +++ b/tests/api/controller/test_loan.py @@ -1,5 +1,6 @@ import datetime import urllib.parse +from collections.abc import Generator from decimal import Decimal from unittest.mock import MagicMock, patch @@ -84,8 +85,10 @@ def __init__(self, db: DatabaseTransactionFixture): @pytest.fixture(scope="function") -def loan_fixture(db: DatabaseTransactionFixture): - return LoanFixture(db) +def loan_fixture(db: DatabaseTransactionFixture) -> Generator[LoanFixture, None, None]: + fixture = LoanFixture(db) + with fixture.wired_container(): + yield fixture class TestLoanController: @@ -179,7 +182,21 @@ def test_patron_circulation_retrieval(self, loan_fixture: LoanFixture): ) assert (hold, other_pool) == result - def test_borrow_success(self, loan_fixture: LoanFixture): + @pytest.mark.parametrize( + "accept_header,expected_content_type_prefix", + [ + (None, "application/atom+xml"), + ("default-foo-bar", "application/atom+xml"), + ("application/atom+xml", "application/atom+xml"), + ("application/opds+json", "application/opds+json"), + ], + ) + def test_borrow_success( + self, + loan_fixture: LoanFixture, + accept_header: str | None, + expected_content_type_prefix, + ): # Create a loanable LicensePool. work = loan_fixture.db.work( with_license_pool=True, with_open_access_download=False @@ -196,9 +213,36 @@ def test_borrow_success(self, loan_fixture: LoanFixture): utc_now() + datetime.timedelta(seconds=3600), ), ) - with loan_fixture.request_context_with_library( - "/", headers=dict(Authorization=loan_fixture.valid_auth) - ): + + # Setup headers for the request. + headers = {"Authorization": loan_fixture.valid_auth} | ( + {"Accept": accept_header} if accept_header else {} + ) + + # Create a new loan. + with loan_fixture.request_context_with_library("/", headers=headers): + loan_fixture.manager.loans.authenticated_patron_from_request() + response = loan_fixture.manager.loans.borrow( + loan_fixture.identifier.type, loan_fixture.identifier.identifier + ) + loan = get_one( + loan_fixture.db.session, Loan, license_pool=loan_fixture.pool + ) + + # A new loan should return a 201 status. + assert 201 == response.status_code + + # A loan has been created for this license pool. + assert loan is not None + # The loan has yet to be fulfilled. + assert loan.fulfillment is None + + # We've been given an OPDS feed with one entry, which tells us how + # to fulfill the license. + new_feed_content = response.get_data() + + # Borrow again with an existing loan. + with loan_fixture.request_context_with_library("/", headers=headers): loan_fixture.manager.loans.authenticated_patron_from_request() response = loan_fixture.manager.loans.borrow( loan_fixture.identifier.type, loan_fixture.identifier.identifier @@ -208,15 +252,28 @@ def test_borrow_success(self, loan_fixture: LoanFixture): loan = get_one( loan_fixture.db.session, Loan, license_pool=loan_fixture.pool ) + # An existing loan should return a 200 status. + assert 200 == response.status_code + + # There is still a loan that has not yet been fulfilled. assert loan is not None - # The loan has yet to be fulfilled. - assert None == loan.fulfillment + assert loan.fulfillment is None # We've been given an OPDS feed with one entry, which tells us how # to fulfill the license. - assert 201 == response.status_code - feed = feedparser.parse(response.get_data()) - [entry] = feed["entries"] + existing_feed_content = response.get_data() + + # The new loan feed should look the same as the existing loan feed. + assert new_feed_content == existing_feed_content + + if expected_content_type_prefix == "application/atom+xml": + assert response.content_type.startswith("application/atom+xml") + feed = feedparser.parse(response.get_data()) + [entry] = feed["entries"] + elif expected_content_type_prefix == "application/opds+json": + assert "application/opds+json" == response.content_type + entry = response.get_json() + fulfillment_links = [ x["href"] for x in entry["links"] diff --git a/tests/api/controller/test_opds_feed.py b/tests/api/controller/test_opds_feed.py index 4ed4885b8..499f3554c 100644 --- a/tests/api/controller/test_opds_feed.py +++ b/tests/api/controller/test_opds_feed.py @@ -6,9 +6,7 @@ import feedparser from flask import url_for -from api.circulation_manager import CirculationManager from api.lanes import HasSeriesFacets, JackpotFacets, JackpotWorkList -from api.problem_details import REMOTE_INTEGRATION_FAILED from core.app_server import load_facets_from_request from core.entrypoint import AudiobooksEntryPoint, EverythingEntryPoint from core.external_search import SortKeyPagination @@ -78,11 +76,6 @@ def test_feed( == response.uri ) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds.feed(lane_id) - ) - # Now let's make a real feed. # Set up configuration settings for links and entry points @@ -94,8 +87,11 @@ def test_feed( settings.about = "d" # type: ignore[assignment] # Make a real OPDS feed and poke at it. - with circulation_fixture.request_context_with_library( - "/?entrypoint=Book&size=10" + with ( + circulation_fixture.request_context_with_library( + "/?entrypoint=Book&size=10" + ), + circulation_fixture.wired_container(), ): response = circulation_fixture.manager.opds_feeds.feed( circulation_fixture.english_adult_fiction.id @@ -276,11 +272,6 @@ def test_groups( == response.uri ) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds.groups(None) - ) - # A grouped feed has no pagination, and the FeaturedFacets # constructor never raises an exception. So we don't need to # test for those error conditions. @@ -491,11 +482,6 @@ def test_search( == response.uri ) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: circulation_fixture.manager.opds_feeds.search(None) - ) - # Loading the SearchFacets object from a request can't return # a problem detail, so we can't test that case. @@ -666,28 +652,6 @@ def test_lane_search_params( == response.detail ) - def test_misconfigured_search( - self, circulation_fixture: CirculationControllerFixture - ): - circulation_fixture.add_works(self._EXTRA_BOOKS) - - class BadSearch(CirculationManager): - @property - def setup_search(self): - raise Exception("doomed!") - - circulation = BadSearch(circulation_fixture.db.session) - - # An attempt to call FeedController.search() will return a - # problem detail. - with circulation_fixture.request_context_with_library("/?q=t"): - problem = circulation.opds_feeds.search(None) - assert REMOTE_INTEGRATION_FAILED.uri == problem.uri - assert ( - "The search index for this site is not properly configured." - == problem.detail - ) - def test__qa_feed(self, circulation_fixture: CirculationControllerFixture): circulation_fixture.add_works(self._EXTRA_BOOKS) @@ -702,11 +666,6 @@ def test__qa_feed(self, circulation_fixture: CirculationControllerFixture): m = circulation_fixture.manager.opds_feeds._qa_feed args = (feed_method, "QA test feed", "qa_feed", Facets, worklist_factory) - # Bad search index setup -> Problem detail - circulation_fixture.assert_bad_search_index_gives_problem_detail( - lambda: m(*args) - ) - # Bad faceting information -> Problem detail with circulation_fixture.request_context_with_library("/?order=nosuchorder"): response = m(*args) diff --git a/tests/api/controller/test_staticfile.py b/tests/api/controller/test_staticfile.py index e61f1aca6..7916b6381 100644 --- a/tests/api/controller/test_staticfile.py +++ b/tests/api/controller/test_staticfile.py @@ -1,54 +1,32 @@ +from __future__ import annotations + import pytest from werkzeug.exceptions import NotFound -from api.config import Configuration -from core.model import ConfigurationSetting -from tests.fixtures.api_controller import CirculationControllerFixture +from api.controller.static_file import StaticFileController from tests.fixtures.api_images_files import ImageFilesFixture +from tests.fixtures.flask import FlaskAppFixture class TestStaticFileController: def test_static_file( self, - circulation_fixture: CirculationControllerFixture, api_image_files_fixture: ImageFilesFixture, + flask_app_fixture: FlaskAppFixture, ): files = api_image_files_fixture - cache_timeout = ConfigurationSetting.sitewide( - circulation_fixture.db.session, Configuration.STATIC_FILE_CACHE_TIME - ) - cache_timeout.value = 10 - expected_content = files.sample_data("blue.jpg") - with circulation_fixture.app.test_request_context("/"): - response = circulation_fixture.app.manager.static_files.static_file( - files.directory, "blue.jpg" - ) + with flask_app_fixture.test_request_context(): + response = StaticFileController.static_file(files.directory, "blue.jpg") - assert 200 == response.status_code - assert "public, max-age=10" == response.headers.get("Cache-Control") - assert expected_content == response.response.file.read() + assert response.status_code == 200 + assert response.headers.get("Cache-Control") == "no-cache" + assert response.response.file.read() == expected_content - with circulation_fixture.app.test_request_context("/"): + with flask_app_fixture.test_request_context(): pytest.raises( NotFound, - circulation_fixture.app.manager.static_files.static_file, + StaticFileController.static_file, files.directory, "missing.png", ) - - def test_image( - self, - circulation_fixture: CirculationControllerFixture, - resources_files_fixture: ImageFilesFixture, - ): - files = resources_files_fixture - - filename = "FirstBookLoginButton280.png" - expected_content = files.sample_data(filename) - - with circulation_fixture.app.test_request_context("/"): - response = circulation_fixture.app.manager.static_files.image(filename) - - assert 200 == response.status_code - assert expected_content == response.response.file.read() diff --git a/tests/api/controller/test_work.py b/tests/api/controller/test_work.py index 884a384d2..d61d41b03 100644 --- a/tests/api/controller/test_work.py +++ b/tests/api/controller/test_work.py @@ -1,8 +1,9 @@ import datetime import json import urllib.parse +from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import feedparser import flask @@ -18,11 +19,11 @@ SeriesFacets, SeriesLane, ) -from api.novelist import MockNoveListAPI +from api.metadata.novelist import NoveListAPI from api.problem_details import NO_SUCH_LANE, NOT_FOUND_ON_REMOTE from core.classifier import Classifier from core.entrypoint import AudiobooksEntryPoint -from core.external_search import SortKeyPagination, mock_search_index +from core.external_search import SortKeyPagination from core.facets import FacetConstants from core.feed.acquisition import OPDSAcquisitionFeed from core.feed.annotator.circulation import LibraryAnnotator @@ -47,7 +48,6 @@ from core.util.problem_detail import ProblemDetail from tests.fixtures.api_controller import CirculationControllerFixture from tests.fixtures.database import DatabaseTransactionFixture -from tests.mocks.search import fake_hits class WorkFixture(CirculationControllerFixture): @@ -65,8 +65,10 @@ def __init__(self, db: DatabaseTransactionFixture): @pytest.fixture(scope="function") -def work_fixture(db: DatabaseTransactionFixture): - return WorkFixture(db) +def work_fixture(db: DatabaseTransactionFixture) -> Generator[WorkFixture, None, None]: + fixture = WorkFixture(db) + with fixture.wired_container(): + yield fixture class TestWorkController: @@ -101,11 +103,6 @@ def test_contributor(self, work_fixture: WorkFixture): contributor = contributor.display_name - # Search index misconfiguration -> Problem detail - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.series(contributor, None, None) - ) - # Bad facet data -> ProblemDetail with work_fixture.request_context_with_library("/?order=nosuchorder"): response = m(contributor, None, None) @@ -188,7 +185,7 @@ def page(cls, **kwargs): kwargs = self.called_with # type: ignore assert work_fixture.db.session == kwargs.pop("_db") - assert work_fixture.manager._external_search == kwargs.pop("search_engine") + assert work_fixture.manager.external_search == kwargs.pop("search_engine") # The feed is named after the contributor the request asked # about. @@ -499,7 +496,7 @@ def test_recommendations(self, work_fixture: WorkFixture): # Prep an empty recommendation. source = DataSource.lookup(work_fixture.db.session, self.datasource) metadata = Metadata(source) - mock_api = MockNoveListAPI(work_fixture.db.session) + mock_api = create_autospec(NoveListAPI) args = [self.identifier.type, self.identifier.identifier] kwargs: dict[str, Any] = dict(novelist_api=mock_api) @@ -518,13 +515,6 @@ def test_recommendations(self, work_fixture: WorkFixture): ) assert 400 == response.status_code - # Or if the search index is misconfigured. - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.recommendations( - *args, **kwargs - ) - ) - # If no NoveList API is configured, the lane does not exist. with work_fixture.request_context_with_library("/"): response = work_fixture.manager.work_controller.recommendations(*args) @@ -534,12 +524,6 @@ def test_recommendations(self, work_fixture: WorkFixture): # If the NoveList API is configured, the search index is asked # about its recommendations. - # - # This test no longer makes sense, the external_search no longer blindly returns information - # The query_works is not overidden, so we mock it manually - work_fixture.manager.external_search.query_works = MagicMock( - return_value=fake_hits([work_fixture.english_1]) - ) with work_fixture.request_context_with_library("/"): response = work_fixture.manager.work_controller.recommendations( *args, **kwargs @@ -667,13 +651,6 @@ def test_related_books(self, work_fixture: WorkFixture): # creation process -- an invalid entrypoint will simply be # ignored. - # Bad search index setup -> Problem detail - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.related( - identifier.type, identifier.identifier - ) - ) - # The mock search engine will return this Work for every # search. That means this book will show up as a 'same author' # recommendation, a 'same series' recommentation, and a @@ -683,7 +660,7 @@ def test_related_books(self, work_fixture: WorkFixture): ) work_fixture.manager.external_search.mock_query_works([same_author_and_series]) - mock_api = MockNoveListAPI(work_fixture.db.session) + mock_api = create_autospec(NoveListAPI) # Create a fresh book, and set up a mock NoveList API to # recommend its identifier for any input. @@ -697,16 +674,15 @@ def test_related_books(self, work_fixture: WorkFixture): metadata = Metadata(overdrive) recommended_identifier = work_fixture.db.identifier() metadata.recommendations = [recommended_identifier] - mock_api.setup_method(metadata) + mock_api.lookup.return_value = metadata # Now, ask for works related to work_fixture.english_1. - with mock_search_index(work_fixture.manager.external_search): - with work_fixture.request_context_with_library("/?entrypoint=Book"): - response = work_fixture.manager.work_controller.related( - work_fixture.identifier.type, - work_fixture.identifier.identifier, - novelist_api=mock_api, - ) + with work_fixture.request_context_with_library("/?entrypoint=Book"): + response = work_fixture.manager.work_controller.related( + work_fixture.identifier.type, + work_fixture.identifier.identifier, + novelist_api=mock_api, + ) assert 200 == response.status_code assert OPDSFeed.ACQUISITION_FEED_TYPE == response.headers["content-type"] feed = feedparser.parse(response.data) @@ -760,7 +736,6 @@ def groups(cls, **kwargs): resp.as_response.return_value = Response("An OPDS feed") return resp - mock_api.setup_method(metadata) with work_fixture.request_context_with_library("/?entrypoint=Audio"): response = work_fixture.manager.work_controller.related( work_fixture.identifier.type, @@ -871,11 +846,6 @@ def test_series(self, work_fixture: WorkFixture): ) assert 400 == response.status_code - # Or if the search index isn't set up. - work_fixture.assert_bad_search_index_gives_problem_detail( - lambda: work_fixture.manager.work_controller.series(series_name, None, None) - ) - # Set up the mock search engine to return our work no matter # what query it's given. The fact that this book isn't # actually in the series doesn't matter, since determining diff --git a/tests/api/discovery/test_opds_registration.py b/tests/api/discovery/test_opds_registration.py index 60e67f23a..1cc079f0a 100644 --- a/tests/api/discovery/test_opds_registration.py +++ b/tests/api/discovery/test_opds_registration.py @@ -746,7 +746,7 @@ def process_library( # type: ignore[override] "--stage=testing", "--registry-url=http://registry.com/", ] - manager = MockCirculationManager(db.session) + manager = MockCirculationManager(db.session, MagicMock()) script.do_run(cmd_args=cmd_args, manager=manager) # One library was processed. diff --git a/tests/api/feed/fixtures.py b/tests/api/feed/conftest.py similarity index 100% rename from tests/api/feed/fixtures.py rename to tests/api/feed/conftest.py diff --git a/tests/api/feed/test_admin.py b/tests/api/feed/test_admin.py index cd2757880..1fa405a0c 100644 --- a/tests/api/feed/test_admin.py +++ b/tests/api/feed/test_admin.py @@ -5,8 +5,9 @@ from core.lane import Pagination from core.model.datasource import DataSource from core.model.measurement import Measurement -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.search import ExternalSearchFixtureFake class TestOPDS: @@ -20,7 +21,10 @@ def links(self, feed: FeedData, rel=None): return r def test_feed_includes_staff_rating( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -44,7 +48,10 @@ def test_feed_includes_staff_rating( assert Measurement.RATING == entry.computed.ratings[1].additionalType # type: ignore[attr-defined] def test_feed_includes_refresh_link( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -67,7 +74,10 @@ def test_feed_includes_refresh_link( ] def test_feed_includes_suppress_link( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -120,7 +130,10 @@ def test_feed_includes_suppress_link( assert 0 == len(suppress_links) def test_feed_includes_edit_link( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): work = db.work(with_open_access_download=True) lp = work.license_pools[0] @@ -137,7 +150,10 @@ def test_feed_includes_edit_link( assert edit_link.href and lp.identifier.identifier in edit_link.href def test_suppressed_feed( - self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + self, + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ): # Test the ability to show a paginated feed of suppressed works. diff --git a/tests/api/feed/test_annotators.py b/tests/api/feed/test_annotators.py index 3e000914c..b34ea6c4c 100644 --- a/tests/api/feed/test_annotators.py +++ b/tests/api/feed/test_annotators.py @@ -25,7 +25,7 @@ from core.model.resource import Hyperlink, Resource from core.model.work import Work from core.util.datetime_helpers import datetime_utc, utc_now -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor, patch_url_for # noqa from tests.fixtures.database import ( # noqa DatabaseTransactionFixture, DBStatementCounter, diff --git a/tests/api/feed/test_library_annotator.py b/tests/api/feed/test_library_annotator.py index f9b1dff34..dbd75bd94 100644 --- a/tests/api/feed/test_library_annotator.py +++ b/tests/api/feed/test_library_annotator.py @@ -10,8 +10,9 @@ from api.adobe_vendor_id import AuthdataUtility from api.circulation import BaseCirculationAPI, CirculationAPI, FulfillmentInfo +from api.integration.registry.metadata import MetadataRegistry from api.lanes import ContributorLane -from api.novelist import NoveListAPI +from api.metadata.novelist import NoveListAPI, NoveListApiSettings from core.classifier import ( # type: ignore[attr-defined] Classifier, Fantasy, @@ -24,6 +25,7 @@ from core.feed.opds import UnfulfillableWork from core.feed.types import FeedData, WorkEntry from core.feed.util import strftime +from core.integration.goals import Goals from core.lane import Facets, FacetsWithEntryPoint, Pagination from core.lcp.credential import LCPCredentialFactory, LCPHashedPassphrase from core.model import ( @@ -31,7 +33,6 @@ Contributor, DataSource, DeliveryMechanism, - ExternalIntegration, Hyperlink, PresentationCalculationPolicy, Representation, @@ -43,9 +44,10 @@ from core.util.datetime_helpers import utc_now from core.util.flask_util import OPDSFeedResponse from core.util.opds_writer import OPDSFeed -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor, patch_url_for # noqa from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.library import LibraryFixture +from tests.fixtures.search import ExternalSearchFixtureFake from tests.fixtures.vendor_id import VendorIDFixture @@ -77,7 +79,9 @@ def __init__(self, db: DatabaseTransactionFixture): @pytest.fixture(scope="function") def annotator_fixture( - db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + db: DatabaseTransactionFixture, + patch_url_for: PatchedUrlFor, + external_search_fake_fixture: ExternalSearchFixtureFake, ) -> LibraryAnnotatorFixture: return LibraryAnnotatorFixture(db) @@ -863,14 +867,16 @@ def test_work_entry_includes_recommendations_link( ] # There's a recommendation link when configuration is found, though! - NoveListAPI.IS_CONFIGURED = None - annotator_fixture.db.external_integration( - ExternalIntegration.NOVELIST, - goal=ExternalIntegration.METADATA_GOAL, - username="library", - password="sure", + protocol = MetadataRegistry().get_protocol(NoveListAPI) + assert protocol is not None + integration = annotator_fixture.db.integration_configuration( + protocol=protocol, + goal=Goals.METADATA_GOAL, libraries=[annotator_fixture.db.default_library()], ) + NoveListAPI.settings_update( + integration, NoveListApiSettings(username="library", password="sure") + ) feed = self.get_parsed_feed(annotator_fixture, [work]) [entry] = feed.entries diff --git a/tests/api/feed/test_loan_and_hold_annotator.py b/tests/api/feed/test_loan_and_hold_annotator.py index 1af8c79ce..e57656a72 100644 --- a/tests/api/feed/test_loan_and_hold_annotator.py +++ b/tests/api/feed/test_loan_and_hold_annotator.py @@ -16,6 +16,7 @@ from core.model.licensing import LicensePool from core.model.patron import Loan from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.search import ExternalSearchFixtureFake class TestLibraryLoanAndHoldAnnotator: @@ -195,7 +196,11 @@ def test_choose_best_hold_for_work(self, db: DatabaseTransactionFixture): [hold_1, hold_2] ) - def test_annotate_work_entry(self, db: DatabaseTransactionFixture): + def test_annotate_work_entry( + self, + db: DatabaseTransactionFixture, + external_search_fake_fixture: ExternalSearchFixtureFake, + ): library = db.default_library() patron = db.patron() identifier = db.identifier() diff --git a/tests/api/feed/test_opds_acquisition_feed.py b/tests/api/feed/test_opds_acquisition_feed.py index 7fe79458e..22b9f6c2a 100644 --- a/tests/api/feed/test_opds_acquisition_feed.py +++ b/tests/api/feed/test_opds_acquisition_feed.py @@ -36,7 +36,7 @@ from core.util.datetime_helpers import utc_now from core.util.flask_util import OPDSEntryResponse, OPDSFeedResponse from core.util.opds_writer import OPDSFeed, OPDSMessage -from tests.api.feed.fixtures import PatchedUrlFor, patch_url_for # noqa +from tests.api.feed.conftest import PatchedUrlFor, patch_url_for # noqa from tests.fixtures.database import DatabaseTransactionFixture @@ -996,7 +996,7 @@ class TestEntrypointLinkInsertionFixture: @pytest.fixture() def entrypoint_link_insertion_fixture( - db, + db: DatabaseTransactionFixture, ) -> Generator[TestEntrypointLinkInsertionFixture, None, None]: data = TestEntrypointLinkInsertionFixture() data.db = db @@ -1076,7 +1076,7 @@ def run(wl=None, facets=None): MockAnnotator(), None, facets, - search, + search_engine=search, ) return data.mock.called_with diff --git a/tests/api/files/odl2/oa-title.json b/tests/api/files/odl2/oa-title.json new file mode 100644 index 000000000..88fd6a12a --- /dev/null +++ b/tests/api/files/odl2/oa-title.json @@ -0,0 +1,116 @@ +{ + "metadata": { + "title": "Feedbooks" + }, + "links": [ + { + "type": "application/opds+json", + "rel": "self", + "href": "https://market.feedbooks.com/api/libraries/harvest.json" + } + ], + "publications": [ + { + "metadata": { + "@type": "http://schema.org/Book", + "title": "Maria: or, The Wrongs of Woman", + "language": "en", + "modified": "2024-01-17T14:34:03+01:00", + "published": "1798-01-01T00:00:00+00:09", + "identifier": "https://www.feedbooks.com/book/7256", + "schema:encodingFormat": "application/epub+zip", + "presentation": { + "layout": "reflowable" + }, + "author": [ + { + "name": "Mary Wollstonecraft", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/recent.json?author_id=1315&lang=en" + } + ] + } + ], + "publisher": { + "name": "Feedbooks", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/recent.json?lang=en&publisher=Feedbooks" + } + ] + }, + "description": "Wollstonecraft's philosophical and gothic novel revolves around the story of a woman imprisoned in an insane asylum by her husband. It focuses on the societal rather than the individual \"wrongs of woman\" and criticizes what Wollstonecraft viewed as the patriarchal institution of marriage in eighteenth-century Britain and the legal system that protected it. [Source: Wikipedia]", + "subject": [ + { + "code": "FBFIC000000", + "name": "Fiction", + "scheme": "http://www.feedbooks.com/categories", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/top.json?cat=FBFIC000000&lang=en" + } + ] + }, + { + "code": "FBFIC019000", + "name": "Literary", + "scheme": "http://www.feedbooks.com/categories", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/top.json?cat=FBFIC019000&lang=en" + } + ] + }, + { + "code": "READ0000", + "scheme": "http://schema.org/Audience", + "name": "Adult", + "links": [ + { + "type": "application/opds+json", + "href": "https://market.feedbooks.com/publicdomain/browse/top.json?age=READ0000&lang=en" + } + ] + } + ] + }, + "images": [ + { + "href": "https://covers.feedbooks.net/book/7256.jpg?size=large&t=1549045914", + "type": "image/jpeg", + "width": 260, + "height": 420 + }, + { + "href": "https://covers.feedbooks.net/book/7256.jpg?t=1549045914", + "type": "image/jpeg", + "width": 100, + "height": 180 + } + ], + "links": [ + { + "rel": "http://opds-spec.org/acquisition/open-access", + "href": "https://license.feedbooks.net/loan/open_content", + "type": "application/epub+zip" + }, + { + "rel": "self", + "href": "https://market.feedbooks.com/book/7256.json", + "type": "application/opds-publication+json", + "properties": { + "authenticate": { + "href": "https://market.feedbooks.com/user/authentication", + "type": "application/opds-authentication+json" + } + } + } + ] + } + ] +} diff --git a/tests/api/metadata/__init__.py b/tests/api/metadata/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/api/test_novelist.py b/tests/api/metadata/test_novelist.py similarity index 70% rename from tests/api/test_novelist.py rename to tests/api/metadata/test_novelist.py index 0fa3e2560..93c90a615 100644 --- a/tests/api/test_novelist.py +++ b/tests/api/metadata/test_novelist.py @@ -1,12 +1,15 @@ import datetime import json +from unittest.mock import MagicMock, create_autospec import pytest +from _pytest.monkeypatch import MonkeyPatch from api.config import CannotLoadConfiguration -from api.novelist import MockNoveListAPI, NoveListAPI +from api.metadata.novelist import NoveListAPI, NoveListApiSettings +from core.integration.goals import Goals from core.metadata_layer import Metadata -from core.model import DataSource, ExternalIntegration, Identifier +from core.model import DataSource, Identifier from core.util.http import HTTP from tests.core.mock import DummyHTTPClient, MockRequestsResponse from tests.fixtures.api_novelist_files import NoveListFilesFixture @@ -14,21 +17,16 @@ class NoveListFixture: - db: DatabaseTransactionFixture - files: NoveListFilesFixture - integration: ExternalIntegration - novelist: NoveListAPI - def __init__(self, db: DatabaseTransactionFixture, files: NoveListFilesFixture): self.db = db self.files = files - self.integration = db.external_integration( - ExternalIntegration.NOVELIST, - ExternalIntegration.METADATA_GOAL, - username="library", - password="yep", + self.settings = NoveListApiSettings(username="library", password="yep") + self.integration = db.integration_configuration( + "NoveList Select", + Goals.METADATA_GOAL, libraries=[db.default_library()], ) + NoveListAPI.settings_update(self.integration, self.settings) self.novelist = NoveListAPI.from_config(db.default_library()) def sample_data(self, filename): @@ -38,9 +36,6 @@ def sample_representation(self, filename): content = self.sample_data(filename) return self.db.representation(media_type="application/json", content=content)[0] - def close(self): - NoveListAPI.IS_CONFIGURED = None - @pytest.fixture(scope="function") def novelist_fixture( @@ -48,7 +43,6 @@ def novelist_fixture( ): fixture = NoveListFixture(db, api_novelist_files_fixture) yield fixture - fixture.close() class TestNoveListAPI: @@ -57,50 +51,39 @@ class TestNoveListAPI: def test_from_config(self, novelist_fixture: NoveListFixture): """Confirms that NoveListAPI can be built from config successfully""" novelist = NoveListAPI.from_config(novelist_fixture.db.default_library()) - assert True == isinstance(novelist, NoveListAPI) - assert "library" == novelist.profile - assert "yep" == novelist.password - - # Without either configuration value, an error is raised. - novelist_fixture.integration.password = None - pytest.raises( - CannotLoadConfiguration, - NoveListAPI.from_config, - novelist_fixture.db.default_library(), - ) + assert isinstance(novelist, NoveListAPI) is True + assert novelist.profile == "library" + assert novelist.password == "yep" - novelist_fixture.integration.password = "yep" - novelist_fixture.integration.username = None + # If the integration is not configured, an error is raised. + another_library = novelist_fixture.db.library() pytest.raises( CannotLoadConfiguration, NoveListAPI.from_config, - novelist_fixture.db.default_library(), + another_library, ) def test_is_configured(self, novelist_fixture: NoveListFixture): - # If an ExternalIntegration exists, the API is_configured - assert True == NoveListAPI.is_configured(novelist_fixture.db.default_library()) - # A class variable is set to reduce future database requests. - assert ( - novelist_fixture.db.default_library().id - == NoveListAPI._configuration_library_id - ) + # If a IntegrationLibraryConfiguration exists, the API is_configured + assert NoveListAPI.is_configured(novelist_fixture.db.default_library()) is True # If an ExternalIntegration doesn't exist for the library, it is not. library = novelist_fixture.db.library() - assert False == NoveListAPI.is_configured(library) - # And the class variable is updated. - assert library.id == NoveListAPI._configuration_library_id + assert NoveListAPI.is_configured(library) is False def test_review_response(self, novelist_fixture: NoveListFixture): - invalid_credential_response = (403, {}, b"HTML Access Denied page") # type: ignore + invalid_credential_response: tuple[int, dict[str, str], bytes] = ( + 403, + {}, + b"HTML Access Denied page", + ) pytest.raises( Exception, novelist_fixture.novelist.review_response, invalid_credential_response, ) - missing_argument_response = ( # type: ignore + missing_argument_response: tuple[int, dict[str, str], bytes] = ( 200, {}, b'"Missing ISBN, UPC, or Client Identifier!"', @@ -111,8 +94,8 @@ def test_review_response(self, novelist_fixture: NoveListFixture): missing_argument_response, ) - response = (200, {}, b"Here's the goods!") # type: ignore - assert response == novelist_fixture.novelist.review_response(response) + response: tuple[int, dict[str, str], bytes] = (200, {}, b"Here's the goods!") + novelist_fixture.novelist.review_response(response) def test_lookup_info_to_metadata(self, novelist_fixture: NoveListFixture): # Basic book information is returned @@ -122,35 +105,35 @@ def test_lookup_info_to_metadata(self, novelist_fixture: NoveListFixture): bad_character = novelist_fixture.sample_representation("a_bad_character.json") metadata = novelist_fixture.novelist.lookup_info_to_metadata(bad_character) - assert True == isinstance(metadata, Metadata) - assert Identifier.NOVELIST_ID == metadata.primary_identifier.type - assert "10392078" == metadata.primary_identifier.identifier - assert "A bad character" == metadata.title - assert None == metadata.subtitle - assert 1 == len(metadata.contributors) + assert isinstance(metadata, Metadata) + assert metadata.primary_identifier.type == Identifier.NOVELIST_ID + assert metadata.primary_identifier.identifier == "10392078" + assert metadata.title == "A bad character" + assert metadata.subtitle is None + assert len(metadata.contributors) == 1 [contributor] = metadata.contributors - assert "Kapoor, Deepti" == contributor.sort_name - assert 4 == len(metadata.identifiers) - assert 4 == len(metadata.subjects) - assert 2 == len(metadata.measurements) + assert contributor.sort_name == "Kapoor, Deepti" + assert len(metadata.identifiers) == 4 + assert len(metadata.subjects) == 4 + assert len(metadata.measurements) == 2 ratings = sorted(metadata.measurements, key=lambda m: m.value) - assert 2 == ratings[0].value - assert 3.27 == ratings[1].value - assert 625 == len(metadata.recommendations) + assert ratings[0].value == 2 + assert ratings[1].value == 3.27 + assert len(metadata.recommendations) == 625 # Confirm that Lexile and series data is extracted with a # different sample. vampire = novelist_fixture.sample_representation("vampire_kisses.json") metadata = novelist_fixture.novelist.lookup_info_to_metadata(vampire) - - [lexile] = filter(lambda s: s.type == "Lexile", metadata.subjects) - assert "630" == lexile.identifier - assert "Vampire kisses manga" == metadata.series + assert isinstance(metadata, Metadata) + [lexile] = [s for s in metadata.subjects if s.type == "Lexile"] + assert lexile.identifier == "630" + assert metadata.series == "Vampire kisses manga" # The full title should be selected, since every volume # has the same main title: 'Vampire kisses' - assert "Vampire kisses: blood relatives. Volume 1" == metadata.title - assert 1 == metadata.series_position - assert 5 == len(metadata.recommendations) + assert metadata.title == "Vampire kisses: blood relatives. Volume 1" + assert metadata.series_position == 1 + assert len(metadata.recommendations) == 5 def test_get_series_information(self, novelist_fixture: NoveListFixture): metadata = Metadata(data_source=DataSource.NOVELIST) @@ -162,11 +145,11 @@ def test_get_series_information(self, novelist_fixture: NoveListFixture): metadata, series_info, book_info ) # Relevant series information is extracted - assert "Vampire kisses manga" == metadata.series - assert 1 == metadata.series_position + assert metadata.series == "Vampire kisses manga" + assert metadata.series_position == 1 # The 'full_title' key should be returned as ideal because # all the volumes have the same 'main_title' - assert "full_title" == ideal_title_key + assert ideal_title_key == "full_title" watchman = json.loads( novelist_fixture.sample_data("alternate_series_example.json") @@ -184,10 +167,10 @@ def test_get_series_information(self, novelist_fixture: NoveListFixture): (metadata, ideal_title_key) = novelist_fixture.novelist.get_series_information( metadata, series_info, book_info ) - assert "Elvis Cole/Joe Pike novels" == metadata.series - assert 11 == metadata.series_position + assert metadata.series == "Elvis Cole/Joe Pike novels" + assert metadata.series_position == 11 # And recommends using the main_title - assert "main_title" == ideal_title_key + assert ideal_title_key == "main_title" # If the volume is found in the series more than once... book_info = dict( @@ -221,23 +204,26 @@ def test_lookup(self, novelist_fixture: NoveListFixture): h = DummyHTTPClient() h.queue_response(200, "text/html", content="yay") - class Mock(NoveListAPI): - def build_query_url(self, params): - self.build_query_url_called_with = params - return "http://query-url/" + novelist = novelist_fixture.novelist - def scrubbed_url(self, params): - self.scrubbed_url_called_with = params - return "http://scrubbed-url/" + mock_build_query_url = create_autospec( + novelist.build_query_url, return_value="http://query-url/" + ) + novelist.build_query_url = mock_build_query_url + + mock_scrubbed_url = create_autospec( + novelist.scrubbed_url, return_value="http://scrubbed-url/" + ) + novelist.scrubbed_url = mock_scrubbed_url - def review_response(self, response): - self.review_response_called_with = response + mock_review_response = create_autospec(novelist.review_response) + novelist.review_response = mock_review_response - def lookup_info_to_metadata(self, representation): - self.lookup_info_to_metadata_called_with = representation - return "some metadata" + mock_lookup_info_to_metadata = create_autospec( + novelist.lookup_info_to_metadata, return_value="some metadata" + ) + novelist.lookup_info_to_metadata = mock_lookup_info_to_metadata - novelist = Mock.from_config(novelist_fixture.db.default_library()) identifier = novelist_fixture.db.identifier(identifier_type=Identifier.ISBN) # Do the lookup. @@ -247,30 +233,27 @@ def lookup_info_to_metadata(self, representation): # get the URL of the HTTP request. The same parameters were # also passed into scrubbed_url(), to get the URL that should # be used when storing the Representation in the database. - params1 = novelist.build_query_url_called_with - params2 = novelist.scrubbed_url_called_with - assert params1 == params2 + assert mock_build_query_url.call_args == mock_scrubbed_url.call_args - assert ( - dict( - profile=novelist.profile, - ClientIdentifier=identifier.urn, - ISBN=identifier.identifier, - password=novelist.password, - version=novelist.version, - ) - == params1 + assert mock_build_query_url.call_args.args[0] == dict( + profile=novelist.profile, + ClientIdentifier=identifier.urn, + ISBN=identifier.identifier, + password=novelist.password, + version=novelist.version, ) # The HTTP request went out to the query URL -- not the scrubbed URL. assert ["http://query-url/"] == h.requests # The HTTP response was passed into novelist.review_response() - assert ( - 200, - {"content-type": "text/html"}, - b"yay", - ) == novelist.review_response_called_with + mock_review_response.assert_called_once_with( + ( + 200, + {"content-type": "text/html"}, + b"yay", + ) + ) # Finally, the Representation was passed into # lookup_info_to_metadata, which returned a hard-coded string @@ -280,7 +263,8 @@ def lookup_info_to_metadata(self, representation): # Looking at the Representation we can see that it was stored # in the database under its scrubbed URL, not the URL used to # make the request. - rep = novelist.lookup_info_to_metadata_called_with + mock_lookup_info_to_metadata.assert_called_once() + rep = mock_lookup_info_to_metadata.call_args.args[0] assert "http://scrubbed-url/" == rep.url assert b"yay" == rep.content @@ -291,13 +275,13 @@ def test_lookup_info_to_metadata_ignores_empty_responses( null_response = novelist_fixture.sample_representation("null_data.json") result = novelist_fixture.novelist.lookup_info_to_metadata(null_response) - assert None == result + assert result is None # This also happens when NoveList indicates with an empty # response that it doesn't know the ISBN. empty_response = novelist_fixture.sample_representation("unknown_isbn.json") result = novelist_fixture.novelist.lookup_info_to_metadata(empty_response) - assert None == result + assert result is None def test_build_query_url(self, novelist_fixture: NoveListFixture): params = dict( @@ -311,7 +295,7 @@ def test_build_query_url(self, novelist_fixture: NoveListFixture): # Authentication information is included in the URL by default full_result = novelist_fixture.novelist.build_query_url(params) auth_details = "&profile=username&password=secret" - assert True == full_result.endswith(auth_details) + assert full_result.endswith(auth_details) is True assert "profile=username" in full_result assert "password=secret" in full_result @@ -319,7 +303,7 @@ def test_build_query_url(self, novelist_fixture: NoveListFixture): scrubbed_result = novelist_fixture.novelist.build_query_url( params, include_auth=False ) - assert False == scrubbed_result.endswith(auth_details) + assert scrubbed_result.endswith(auth_details) is False assert "profile=username" not in scrubbed_result assert "password=secret" not in scrubbed_result @@ -331,16 +315,16 @@ def test_build_query_url(self, novelist_fixture: NoveListFixture): # The method to create a scrubbed url returns the same result # as the NoveListAPI.build_query_url - assert scrubbed_result == novelist_fixture.novelist.scrubbed_url(params) + assert novelist_fixture.novelist.scrubbed_url(params) == scrubbed_result def test_scrub_subtitle(self, novelist_fixture: NoveListFixture): """Unnecessary title segments are removed from subtitles""" scrub = novelist_fixture.novelist._scrub_subtitle - assert None == scrub(None) - assert None == scrub("[electronic resource]") - assert None == scrub("[electronic resource] : ") - assert "A Biomythography" == scrub("[electronic resource] : A Biomythography") + assert scrub(None) is None + assert scrub("[electronic resource]") is None + assert scrub("[electronic resource] : ") is None + assert scrub("[electronic resource] : A Biomythography") == "A Biomythography" def test_confirm_same_identifier(self, novelist_fixture: NoveListFixture): source = DataSource.lookup(novelist_fixture.db.session, DataSource.NOVELIST) @@ -354,71 +338,68 @@ def test_confirm_same_identifier(self, novelist_fixture: NoveListFixture): match = Metadata(source, primary_identifier=identifier) mistake = Metadata(source, primary_identifier=unmatched_identifier) - assert False == novelist_fixture.novelist._confirm_same_identifier( - [metadata, mistake] + assert ( + novelist_fixture.novelist._confirm_same_identifier([metadata, mistake]) + is False ) - assert True == novelist_fixture.novelist._confirm_same_identifier( - [metadata, match] + assert ( + novelist_fixture.novelist._confirm_same_identifier([metadata, match]) + is True ) def test_lookup_equivalent_isbns(self, novelist_fixture: NoveListFixture): identifier = novelist_fixture.db.identifier( identifier_type=Identifier.OVERDRIVE_ID ) - api = MockNoveListAPI.from_config(novelist_fixture.db.default_library()) + api = novelist_fixture.novelist + mock_lookup = create_autospec(api.lookup) + api.lookup = mock_lookup # If there are no ISBN equivalents, it returns None. - assert None == api.lookup_equivalent_isbns(identifier) + assert api.lookup_equivalent_isbns(identifier) is None source = DataSource.lookup(novelist_fixture.db.session, DataSource.OVERDRIVE) identifier.equivalent_to(source, novelist_fixture.db.identifier(), strength=1) novelist_fixture.db.session.commit() - assert None == api.lookup_equivalent_isbns(identifier) + assert api.lookup_equivalent_isbns(identifier) is None # If there's an ISBN equivalent, but it doesn't result in metadata, # it returns none. isbn = novelist_fixture.db.identifier(identifier_type=Identifier.ISBN) identifier.equivalent_to(source, isbn, strength=1) novelist_fixture.db.session.commit() - api.responses.append(None) - assert None == api.lookup_equivalent_isbns(identifier) - - # Create an API class that can mockout NoveListAPI.choose_best_metadata - class MockBestMetadataAPI(MockNoveListAPI): - choose_best_metadata_return = None + mock_lookup.return_value = None + assert api.lookup_equivalent_isbns(identifier) is None - def choose_best_metadata(self, *args, **kwargs): - return self.choose_best_metadata_return - - api = MockBestMetadataAPI.from_config(novelist_fixture.db.default_library()) + # Create an API class that can mockout NoveListAPI.choose_best_metadata, + # and make sure lookup returns something + mock_choose_best_metadata = create_autospec(api.choose_best_metadata) + api.choose_best_metadata = mock_choose_best_metadata + mock_lookup.return_value = create_autospec(Metadata) # Give the identifier another ISBN equivalent. isbn2 = novelist_fixture.db.identifier(identifier_type=Identifier.ISBN) identifier.equivalent_to(source, isbn2, strength=1) novelist_fixture.db.session.commit() - # Queue metadata responses for each ISBN lookup. - metadatas = [object(), object()] - api.responses.extend(metadatas) - # If choose_best_metadata returns None, the lookup returns None. - api.choose_best_metadata_return = (None, None) - assert None == api.lookup_equivalent_isbns(identifier) + mock_lookup.reset_mock() + mock_choose_best_metadata.return_value = (None, None) + assert api.lookup_equivalent_isbns(identifier) is None # Lookup was performed for both ISBNs. - assert [] == api.responses + assert mock_lookup.call_count == 2 # If choose_best_metadata returns a low confidence metadata, the # lookup returns None. - api.responses.extend(metadatas) - api.choose_best_metadata_return = (metadatas[0], 0.33) - assert None == api.lookup_equivalent_isbns(identifier) + mock_best_metadata = MagicMock() + mock_choose_best_metadata.return_value = (mock_best_metadata, 0.33) + assert api.lookup_equivalent_isbns(identifier) is None # If choose_best_metadata returns a high confidence metadata, the # lookup returns the metadata. - api.responses.extend(metadatas) - api.choose_best_metadata_return = (metadatas[1], 0.67) - assert metadatas[1] == api.lookup_equivalent_isbns(identifier) + mock_choose_best_metadata.return_value = (mock_best_metadata, 0.67) + assert api.lookup_equivalent_isbns(identifier) is mock_best_metadata def test_choose_best_metadata(self, novelist_fixture: NoveListFixture): more_identifier = novelist_fixture.db.identifier( @@ -433,18 +414,18 @@ def test_choose_best_metadata(self, novelist_fixture: NoveListFixture): result = novelist_fixture.novelist.choose_best_metadata( metadatas, novelist_fixture.db.identifier() ) - assert True == isinstance(result, tuple) - assert metadatas[0] == result[0] + assert isinstance(result, tuple) is True + assert result[0] == metadatas[0] # A default confidence of 1.0 is returned. - assert 1.0 == result[1] + assert result[1] == 1.0 # When top identifiers have equal representation, the method returns none. metadatas.append( Metadata(DataSource.NOVELIST, primary_identifier=less_identifier) ) - assert (None, None) == novelist_fixture.novelist.choose_best_metadata( + assert novelist_fixture.novelist.choose_best_metadata( metadatas, novelist_fixture.db.identifier() - ) + ) == (None, None) # But when one pulls ahead, we get the metadata object again. metadatas.append( @@ -453,18 +434,19 @@ def test_choose_best_metadata(self, novelist_fixture: NoveListFixture): result = novelist_fixture.novelist.choose_best_metadata( metadatas, novelist_fixture.db.identifier() ) - assert True == isinstance(result, tuple) + assert isinstance(result, tuple) metadata, confidence = result - assert True == isinstance(metadata, Metadata) - assert 0.67 == round(confidence, 2) - assert more_identifier == metadata.primary_identifier + assert isinstance(metadata, Metadata) + assert isinstance(confidence, float) + assert round(confidence, 2) == 0.67 + assert metadata.primary_identifier == more_identifier def test_get_items_from_query(self, novelist_fixture: NoveListFixture): items = novelist_fixture.novelist.get_items_from_query( novelist_fixture.db.default_library() ) # There are no books in the current library. - assert items == [] + assert [] == items # Set up a book for this library. edition = novelist_fixture.db.edition( @@ -492,7 +474,7 @@ def test_get_items_from_query(self, novelist_fixture: NoveListFixture): publicationDate=edition.published.strftime("%Y%m%d"), ) - assert items == [item] + assert [item] == items def test_create_item_object(self, novelist_fixture: NoveListFixture): # We pass no identifier or item to process so we get nothing back. @@ -502,10 +484,10 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): newItem, addItem, ) = novelist_fixture.novelist.create_item_object(None, None, None) - assert currentIdentifier == None - assert existingItem == None - assert newItem == None - assert addItem == False + assert currentIdentifier is None + assert existingItem is None + assert newItem is None + assert addItem is False # Item row from the db query # (identifier, identifier type, identifier, @@ -562,7 +544,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): novelist_fixture.novelist.create_item_object(book1_from_query, None, None) ) assert currentIdentifier == book1_from_query[2] - assert existingItem == None + assert existingItem is None assert newItem == { "isbn": "23456", "mediaType": "EBook", @@ -574,7 +556,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): } # We want to still process this item along with the next one in case # the following one has the same ISBN. - assert addItem == False + assert addItem is False # Note that `newItem` is what we get from the previous call from `create_item_object`. # We are now processing the previous object along with the new one. @@ -598,8 +580,8 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "20020101", } - assert newItem == None - assert addItem == False + assert newItem is None + assert addItem is False # Test that a narrator gets added along with an author. ( @@ -624,8 +606,8 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "20020101", } - assert newItem == None - assert addItem == False + assert newItem is None + assert addItem is False # New Object ( @@ -656,7 +638,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "14140101", } - assert addItem == True + assert addItem is True # New Object # Test that a narrator got added but not an author @@ -670,7 +652,7 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): ) assert currentIdentifier == book1_narrator_from_query[2] - assert existingItem == None + assert existingItem is None assert newItem == { "isbn": "23456", "mediaType": "EBook", @@ -680,26 +662,29 @@ def test_create_item_object(self, novelist_fixture: NoveListFixture): "distributor": "Gutenberg", "publicationDate": "20020101", } - assert addItem == False + assert addItem is False def test_put_items_novelist(self, novelist_fixture: NoveListFixture): + mock_http_put = create_autospec(novelist_fixture.novelist.put) + novelist_fixture.novelist.put = mock_http_put + mock_http_put.side_effect = Exception("Failed to put items") + + # No items, so put never gets called and none gets returned response = novelist_fixture.novelist.put_items_novelist( novelist_fixture.db.default_library() ) - - assert response == None + assert response is None + assert mock_http_put.call_count == 0 edition = novelist_fixture.db.edition(identifier_type=Identifier.ISBN) pool = novelist_fixture.db.licensepool( edition, collection=novelist_fixture.db.default_collection() ) mock_response = {"Customer": "NYPL", "RecordsReceived": 10} - - def mockHTTPPut(url, headers, **kwargs): - return MockRequestsResponse(200, content=json.dumps(mock_response)) - - oldPut = novelist_fixture.novelist.put - novelist_fixture.novelist.put = mockHTTPPut + mock_http_put.side_effect = None + mock_http_put.return_value = MockRequestsResponse( + 200, content=json.dumps(mock_response) + ) response = novelist_fixture.novelist.put_items_novelist( novelist_fixture.db.default_library() @@ -707,10 +692,8 @@ def mockHTTPPut(url, headers, **kwargs): assert response == mock_response - novelist_fixture.novelist.put = oldPut - def test_make_novelist_data_object(self, novelist_fixture: NoveListFixture): - bad_data = [] # type: ignore + bad_data: list[dict[str, str]] = [] result = novelist_fixture.novelist.make_novelist_data_object(bad_data) assert result == {"customer": "library:yep", "records": []} @@ -733,25 +716,14 @@ def test_make_novelist_data_object(self, novelist_fixture: NoveListFixture): assert result == {"customer": "library:yep", "records": data} - def mockHTTPPut(self, *args, **kwargs): - self.called_with = (args, kwargs) - - def test_put(self, novelist_fixture: NoveListFixture): - oldPut = HTTP.put_with_timeout + def test_put(self, novelist_fixture: NoveListFixture, monkeypatch: MonkeyPatch): + mock_put = create_autospec(HTTP.put_with_timeout) + monkeypatch.setattr(HTTP, "put_with_timeout", mock_put) - HTTP.put_with_timeout = self.mockHTTPPut # type: ignore + headers = {"AuthorizedIdentifier": "authorized!"} + data = ["12345", "12346", "12347"] - try: - headers = {"AuthorizedIdentifier": "authorized!"} - isbns = ["12345", "12346", "12347"] - data = novelist_fixture.novelist.make_novelist_data_object(isbns) - - response = novelist_fixture.novelist.put( - "http://apiendpoint.com", headers, data=data - ) - (params, args) = self.called_with - - assert params == ("http://apiendpoint.com", data) - assert args["headers"] == headers - finally: - HTTP.put_with_timeout = oldPut # type: ignore + novelist_fixture.novelist.put("http://apiendpoint.com", headers, data=data) + mock_put.assert_called_once_with( + "http://apiendpoint.com", data, headers=headers, timeout=None + ) diff --git a/tests/api/test_nyt.py b/tests/api/metadata/test_nyt.py similarity index 90% rename from tests/api/test_nyt.py rename to tests/api/metadata/test_nyt.py index 8aea651c0..c24c60d85 100644 --- a/tests/api/test_nyt.py +++ b/tests/api/metadata/test_nyt.py @@ -1,11 +1,20 @@ import datetime import json +from unittest.mock import MagicMock import pytest -from api.nyt import NYTAPI, NYTBestSellerAPI, NYTBestSellerList, NYTBestSellerListTitle +from api.integration.registry.metadata import MetadataRegistry +from api.metadata.nyt import ( + NYTAPI, + NYTBestSellerAPI, + NytBestSellerApiSettings, + NYTBestSellerList, + NYTBestSellerListTitle, +) from core.config import CannotLoadConfiguration -from core.model import Contributor, CustomListEntry, Edition, ExternalIntegration +from core.integration.goals import Goals +from core.model import Contributor, CustomListEntry, Edition from core.util.http import IntegrationException from tests.fixtures.api_nyt_files import NYTFilesFixture from tests.fixtures.database import DatabaseTransactionFixture @@ -40,6 +49,12 @@ def midnight(self, *args): """ return datetime.datetime(*args, tzinfo=NYTAPI.TIME_ZONE) + def protocol(self) -> str: + registry = MetadataRegistry() + protocol = registry.get_protocol(NYTBestSellerAPI) + assert protocol is not None + return protocol + def __init__(self, db: DatabaseTransactionFixture, files: NYTFilesFixture): self.db = db self.api = DummyNYTBestSellerAPI(db.session, files) @@ -60,28 +75,19 @@ def test_from_config(self, nyt_fixture: NYTBestSellerAPIFixture): # You have to have an ExternalIntegration for the NYT. with pytest.raises(CannotLoadConfiguration) as excinfo: NYTBestSellerAPI.from_config(nyt_fixture.db.session) - assert "No ExternalIntegration found for the NYT." in str(excinfo.value) - integration = nyt_fixture.db.external_integration( - protocol=ExternalIntegration.NYT, goal=ExternalIntegration.METADATA_GOAL - ) - - # It has to have the api key in its 'password' setting. - with pytest.raises(CannotLoadConfiguration) as excinfo: - NYTBestSellerAPI.from_config(nyt_fixture.db.session) - assert "No NYT API key is specified" in str(excinfo.value) + assert "No Integration found for the NYT." in str(excinfo.value) - integration.password = "api key" + integration = nyt_fixture.db.integration_configuration( + protocol=nyt_fixture.protocol(), goal=Goals.METADATA_GOAL + ) + settings = NytBestSellerApiSettings(password="api key") + NYTBestSellerAPI.settings_update(integration, settings) - # It's okay if you don't have a Metadata Wrangler configuration - # configured. api = NYTBestSellerAPI.from_config(nyt_fixture.db.session) assert "api key" == api.api_key - api = NYTBestSellerAPI.from_config(nyt_fixture.db.session) - - # external_integration() finds the integration used to create - # the API object. - assert integration == api.external_integration(nyt_fixture.db.session) + # integration() finds the integration used to create the API object. + assert integration == api.integration(nyt_fixture.db.session) def test_run_self_tests(self, nyt_fixture: NYTBestSellerAPIFixture): class Mock(NYTBestSellerAPI): @@ -91,9 +97,9 @@ def __init__(self): def list_of_lists(self): return "some lists" - [list_test] = Mock()._run_self_tests(object()) + [list_test] = Mock()._run_self_tests(MagicMock()) assert "Getting list of best-seller lists" == list_test.name - assert True == list_test.success + assert list_test.success is True assert "some lists" == list_test.result def test_list_of_lists(self, nyt_fixture: NYTBestSellerAPIFixture): diff --git a/tests/api/mockapi/circulation.py b/tests/api/mockapi/circulation.py index eb693495d..6e3fffe79 100644 --- a/tests/api/mockapi/circulation.py +++ b/tests/api/mockapi/circulation.py @@ -11,11 +11,9 @@ PatronActivityCirculationAPI, ) from api.circulation_manager import CirculationManager -from core.external_search import ExternalSearchIndex from core.integration.settings import BaseSettings -from core.model import DataSource, Hold, Loan, get_one_or_create -from core.model.configuration import ExternalIntegration -from tests.mocks.search import ExternalSearchIndexFake +from core.model import DataSource, Hold, Loan +from core.service.container import Services class MockPatronActivityCirculationAPI(PatronActivityCirculationAPI, ABC): @@ -173,25 +171,8 @@ def api_for_license_pool(self, licensepool): class MockCirculationManager(CirculationManager): d_circulation: MockCirculationAPI - def __init__(self, db: Session): - super().__init__(db) - - def setup_search(self): - """Set up a search client.""" - integration, _ = get_one_or_create( - self._db, - ExternalIntegration, - goal=ExternalIntegration.SEARCH_GOAL, - protocol=ExternalIntegration.OPENSEARCH, - ) - integration.set_setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY, "test_index" - ) - integration.set_setting( - ExternalSearchIndex.TEST_SEARCH_TERM_KEY, "a search term" - ) - integration.url = "http://does-not-exist.com/" - return ExternalSearchIndexFake(self._db) + def __init__(self, db: Session, services: Services): + super().__init__(db, services) def setup_circulation(self, library, analytics): """Set up the Circulation object.""" diff --git a/tests/api/test_controller_cm.py b/tests/api/test_controller_cm.py index 7be453e76..c4aa58176 100644 --- a/tests/api/test_controller_cm.py +++ b/tests/api/test_controller_cm.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock from api.authenticator import LibraryAuthenticator -from api.circulation_manager import CirculationManager from api.config import Configuration from api.custom_index import CustomIndexView from api.problem_details import * @@ -18,7 +17,6 @@ # TODO: we can drop this when we drop support for Python 3.6 and 3.7 from tests.fixtures.api_controller import CirculationControllerFixture from tests.fixtures.database import IntegrationConfigurationFixture -from tests.mocks.search import SearchServiceFake class TestCirculationManager: @@ -34,7 +32,6 @@ def test_load_settings( # Certain fields of the CirculationManager have certain values # which are about to be reloaded. - manager._external_search = object() manager.auth = object() manager.patron_web_domains = object() @@ -109,9 +106,6 @@ def mock_for_library(incoming_library): LibraryAuthenticator, ) - # The ExternalSearch object has been reset. - assert isinstance(manager.external_search.search_service(), SearchServiceFake) - # So have the patron web domains, and their paths have been # removed. assert {"http://sitewide", "http://registration"} == manager.patron_web_domains @@ -146,24 +140,6 @@ def mock_for_library(incoming_library): # Restore the CustomIndexView.for_library implementation CustomIndexView.for_library = old_for_library - def test_exception_during_external_search_initialization_is_stored( - self, circulation_fixture: CirculationControllerFixture - ): - class BadSearch(CirculationManager): - @property - def setup_search(self): - raise Exception("doomed!") - - circulation = BadSearch(circulation_fixture.db.session) - - # We didn't get a search object. - assert None == circulation.external_search - - # The reason why is stored here. - ex = circulation.external_search_initialization_exception - assert isinstance(ex, Exception) - assert "doomed!" == str(ex) - def test_annotator(self, circulation_fixture: CirculationControllerFixture): # Test our ability to find an appropriate OPDSAnnotator for # any request context. diff --git a/tests/api/test_device_tokens.py b/tests/api/test_device_tokens.py index f807a1f8a..24a5c2915 100644 --- a/tests/api/test_device_tokens.py +++ b/tests/api/test_device_tokens.py @@ -1,25 +1,37 @@ from unittest.mock import MagicMock, patch +import pytest + +from api.controller.device_tokens import DeviceTokensController from api.problem_details import DEVICE_TOKEN_NOT_FOUND, DEVICE_TOKEN_TYPE_INVALID from core.model.devicetokens import DeviceToken, DeviceTokenTypes -from tests.fixtures.api_controller import ControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture + + +@pytest.fixture +def controller(db: DatabaseTransactionFixture) -> DeviceTokensController: + mock_manager = MagicMock() + mock_manager._db = db.session + return DeviceTokensController(mock_manager) @patch("api.controller.device_tokens.flask") class TestDeviceTokens: - def test_create_invalid_type(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_create_invalid_type( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): request = MagicMock() request.patron = db.patron() request.json = {"device_token": "xx", "token_type": "aninvalidtoken"} flask.request = request - detail = controller_fixture.app.manager.patron_devices.create_patron_device() + detail = controller.create_patron_device() assert detail is DEVICE_TOKEN_TYPE_INVALID assert detail.status_code == 400 - def test_create_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_create_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): request = MagicMock() request.patron = db.patron() request.json = { @@ -27,7 +39,7 @@ def test_create_token(self, flask, controller_fixture: ControllerFixture): "token_type": DeviceTokenTypes.FCM_ANDROID, } flask.request = request - response = controller_fixture.app.manager.patron_devices.create_patron_device() + response = controller.create_patron_device() assert response[1] == 201 @@ -42,8 +54,9 @@ def test_create_token(self, flask, controller_fixture: ControllerFixture): assert device.device_token == "xxx" assert device.token_type == DeviceTokenTypes.FCM_ANDROID - def test_get_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_get_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create( db.session, DeviceTokenTypes.FCM_ANDROID, "xx", patron @@ -53,14 +66,15 @@ def test_get_token(self, flask, controller_fixture: ControllerFixture): request.patron = patron request.args = {"device_token": "xx"} flask.request = request - response = controller_fixture.app.manager.patron_devices.get_patron_device() + response = controller.get_patron_device() assert response[1] == 200 assert response[0]["token_type"] == DeviceTokenTypes.FCM_ANDROID assert response[0]["device_token"] == "xx" - def test_get_token_not_found(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_get_token_not_found( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create( db.session, DeviceTokenTypes.FCM_ANDROID, "xx", patron @@ -70,14 +84,13 @@ def test_get_token_not_found(self, flask, controller_fixture: ControllerFixture) request.patron = patron request.args = {"device_token": "xxs"} flask.request = request - detail = controller_fixture.app.manager.patron_devices.get_patron_device() + detail = controller.get_patron_device() assert detail == DEVICE_TOKEN_NOT_FOUND def test_get_token_different_patron( - self, flask, controller_fixture: ControllerFixture + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture ): - db = controller_fixture.db patron = db.patron() device = DeviceToken.create( db.session, DeviceTokenTypes.FCM_ANDROID, "xx", patron @@ -87,12 +100,13 @@ def test_get_token_different_patron( request.patron = db.patron() request.args = {"device_token": "xx"} flask.request = request - detail = controller_fixture.app.manager.patron_devices.get_patron_device() + detail = controller.get_patron_device() assert detail == DEVICE_TOKEN_NOT_FOUND - def test_create_duplicate_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_create_duplicate_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create(db.session, DeviceTokenTypes.FCM_IOS, "xxx", patron) @@ -105,7 +119,7 @@ def test_create_duplicate_token(self, flask, controller_fixture: ControllerFixtu } flask.request = request nested = db.session.begin_nested() # rollback only affects device create - response = controller_fixture.app.manager.patron_devices.create_patron_device() + response = controller.create_patron_device() assert response == (dict(exists=True), 200) # different patron same token @@ -117,12 +131,13 @@ def test_create_duplicate_token(self, flask, controller_fixture: ControllerFixtu "token_type": DeviceTokenTypes.FCM_ANDROID, } flask.request = request - response = controller_fixture.app.manager.patron_devices.create_patron_device() + response = controller.create_patron_device() assert response[1] == 201 - def test_delete_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_delete_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create(db.session, DeviceTokenTypes.FCM_IOS, "xxx", patron) @@ -134,14 +149,15 @@ def test_delete_token(self, flask, controller_fixture: ControllerFixture): } flask.request = request - response = controller_fixture.app.manager.patron_devices.delete_patron_device() + response = controller.delete_patron_device() db.session.commit() assert response.status_code == 204 assert db.session.query(DeviceToken).get(device.id) == None - def test_delete_no_token(self, flask, controller_fixture: ControllerFixture): - db = controller_fixture.db + def test_delete_no_token( + self, flask, controller: DeviceTokensController, db: DatabaseTransactionFixture + ): patron = db.patron() device = DeviceToken.create(db.session, DeviceTokenTypes.FCM_IOS, "xxx", patron) @@ -153,5 +169,5 @@ def test_delete_no_token(self, flask, controller_fixture: ControllerFixture): } flask.request = request - response = controller_fixture.app.manager.patron_devices.delete_patron_device() + response = controller.delete_patron_device() assert response == DEVICE_TOKEN_NOT_FOUND diff --git a/tests/api/test_firstbook2.py b/tests/api/test_firstbook2.py deleted file mode 100644 index 432843de1..000000000 --- a/tests/api/test_firstbook2.py +++ /dev/null @@ -1,267 +0,0 @@ -import os -import time -import urllib.parse -from collections.abc import Callable -from functools import partial - -import jwt -import pytest -import requests - -from api.authentication.base import PatronData -from api.authentication.basic import BasicAuthProviderLibrarySettings -from api.circulation_exceptions import RemoteInitiatedServerError -from api.firstbook2 import FirstBookAuthenticationAPI, FirstBookAuthSettings -from tests.fixtures.database import DatabaseTransactionFixture - - -class MockFirstBookResponse: - def __init__(self, status_code, content): - self.status_code = status_code - # Guarantee that the response content is always a bytestring, - # as it would be in real life. - if isinstance(content, str): - content = content.encode("utf8") - self.content = content - - -class MockFirstBookAuthenticationAPI(FirstBookAuthenticationAPI): - SUCCESS = '"Valid Code Pin Pair"' - FAILURE = '{"code":404,"message":"Access Code Pin Pair not found"}' - - def __init__( - self, - library_id, - integration_id, - settings, - library_settings, - valid=None, - bad_connection=False, - failure_status_code=None, - ): - super().__init__(library_id, integration_id, settings, library_settings, None) - - if valid is None: - valid = {} - self.valid = valid - self.bad_connection = bad_connection - self.failure_status_code = failure_status_code - - self.request_urls = [] - - def request(self, url): - self.request_urls.append(url) - if self.bad_connection: - # Simulate a bad connection. - raise requests.exceptions.ConnectionError("Could not connect!") - elif self.failure_status_code: - # Simulate a server returning an unexpected error code. - return MockFirstBookResponse( - self.failure_status_code, "Error %s" % self.failure_status_code - ) - parsed = urllib.parse.urlparse(url) - token = parsed.path.split("/")[-1] - barcode, pin = self._decode(token) - - # The barcode and pin must be present in self.valid. - if barcode in self.valid and self.valid[barcode] == pin: - return MockFirstBookResponse(200, self.SUCCESS) - else: - return MockFirstBookResponse(200, self.FAILURE) - - def _decode(self, token): - # Decode a JWT. Only used in tests -- in production, this is - # First Book's job. - - # The JWT must be signed with the shared secret. - payload = jwt.decode(token, self.secret, algorithms=self.ALGORITHM) - - # The 'iat' field in the payload must be a recent timestamp. - assert (time.time() - int(payload["iat"])) < 2 - - return payload["barcode"], payload["pin"] - - -@pytest.fixture -def mock_library_id() -> int: - return 20 - - -@pytest.fixture -def mock_integration_id() -> int: - return 20 - - -@pytest.fixture -def create_settings() -> Callable[..., FirstBookAuthSettings]: - return partial( - FirstBookAuthSettings, - url="http://example.com/", - password="secret", - ) - - -@pytest.fixture -def create_provider( - mock_library_id: int, - mock_integration_id: int, - create_settings: Callable[..., FirstBookAuthSettings], -) -> Callable[..., MockFirstBookAuthenticationAPI]: - return partial( - MockFirstBookAuthenticationAPI, - library_id=mock_library_id, - integration_id=mock_integration_id, - settings=create_settings(), - library_settings=BasicAuthProviderLibrarySettings(), - valid={"ABCD": "1234"}, - ) - - -class TestFirstBook: - def test_from_config( - self, - create_settings: Callable[..., FirstBookAuthSettings], - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - settings = create_settings( - password="the_key", - ) - provider = create_provider(settings=settings) - - # Verify that the configuration details were stored properly. - assert "http://example.com/" == provider.root - assert "the_key" == provider.secret - - # Test the default server-side authentication regular expressions. - assert provider.server_side_validation("foo' or 1=1 --;", "1234") is False - assert provider.server_side_validation("foo", "12 34") is False - assert provider.server_side_validation("foo", "1234") is True - assert provider.server_side_validation("foo@bar", "1234") is True - - def test_authentication_success( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - - # The mock API successfully decodes the JWT and verifies that - # the given barcode and pin authenticate a specific patron. - assert provider.remote_pin_test("ABCD", "1234") is True - - # Let's see what the mock API had to work with. - requested = provider.request_urls.pop() - assert requested.startswith(provider.root) - token = requested[len(provider.root) :] - - # It's a JWT, with the provided barcode and PIN in the - # payload. - barcode, pin = provider._decode(token) - assert "ABCD" == barcode - assert "1234" == pin - - def test_authentication_failure( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - - assert provider.remote_pin_test("ABCD", "9999") is False - assert provider.remote_pin_test("nosuchkey", "9999") is False - - # credentials are uppercased in remote_authenticate; - # remote_pin_test just passes on whatever it's sent. - assert provider.remote_pin_test("abcd", "9999") is False - - def test_remote_authenticate( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - - patrondata = provider.remote_authenticate("abcd", "1234") - assert isinstance(patrondata, PatronData) - assert "ABCD" == patrondata.permanent_id - assert "ABCD" == patrondata.authorization_identifier - assert patrondata.username is None - - patrondata = provider.remote_authenticate("ABCD", "1234") - assert isinstance(patrondata, PatronData) - assert "ABCD" == patrondata.permanent_id - assert "ABCD" == patrondata.authorization_identifier - assert patrondata.username is None - - # When username is none, the patrondata object should be None - patrondata = provider.remote_authenticate(None, "1234") - assert patrondata is None - - def test_broken_service_remote_pin_test( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider(failure_status_code=502) - with pytest.raises(RemoteInitiatedServerError) as excinfo: - provider.remote_pin_test("key", "pin") - assert "Got unexpected response code 502. Content: Error 502" in str( - excinfo.value - ) - - def test_bad_connection_remote_pin_test( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider(bad_connection=True) - with pytest.raises(RemoteInitiatedServerError) as excinfo: - provider.remote_pin_test("key", "pin") - assert "Could not connect!" in str(excinfo.value) - - def test_authentication_flow_document( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - db: DatabaseTransactionFixture, - ): - # We're about to call url_for, so we must create an - # application context. - provider = create_provider() - os.environ["AUTOINITIALIZE"] = "False" - from api.app import app - - del os.environ["AUTOINITIALIZE"] - with app.test_request_context("/"): - doc = provider.authentication_flow_document(db.session) - assert provider.label() == doc["description"] - assert provider.flow_type == doc["type"] - - def test_jwt( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - ): - provider = create_provider() - # Test the code that generates and signs JWTs. - token = provider.jwt("a barcode", "a pin") - - # The JWT was signed with the shared secret. Decode it (this - # validates it as a side effect) and we can see the payload. - barcode, pin = provider._decode(token) - - assert "a barcode" == barcode - assert "a pin" == pin - - # If the secrets don't match, decoding won't work. - provider.secret = "bad secret" - pytest.raises(jwt.DecodeError, provider._decode, token) - - def test_remote_patron_lookup( - self, - create_provider: Callable[..., MockFirstBookAuthenticationAPI], - db: DatabaseTransactionFixture, - ): - provider = create_provider() - # Remote patron lookup is not supported. It always returns - # the same PatronData object passed into it. - input_patrondata = PatronData() - output_patrondata = provider.remote_patron_lookup(input_patrondata) - assert input_patrondata == output_patrondata - - # if anything else is passed in, it returns None - output_patrondata = provider.remote_patron_lookup(db.patron()) - assert output_patrondata is None diff --git a/tests/api/test_lanes.py b/tests/api/test_lanes.py index 1a716ba2e..5c4a35219 100644 --- a/tests/api/test_lanes.py +++ b/tests/api/test_lanes.py @@ -1,8 +1,9 @@ from collections import Counter -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, create_autospec, patch import pytest +from api.integration.registry.metadata import MetadataRegistry from api.lanes import ( ContributorFacets, ContributorLane, @@ -25,20 +26,15 @@ create_lanes_for_large_collection, create_world_languages_lane, ) -from api.novelist import MockNoveListAPI +from api.metadata.novelist import NoveListAPI +from api.metadata.nyt import NYTBestSellerAPI from core.classifier import Classifier from core.entrypoint import AudiobooksEntryPoint from core.external_search import Filter +from core.integration.goals import Goals from core.lane import DefaultSortOrderFacets, Facets, FeaturedFacets, Lane, WorkList from core.metadata_layer import ContributorData, Metadata -from core.model import ( - Contributor, - DataSource, - Edition, - ExternalIntegration, - Library, - create, -) +from core.model import Contributor, DataSource, Edition, ExternalIntegration, Library from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.library import LibraryFixture from tests.fixtures.search import ExternalSearchFixtureFake @@ -123,11 +119,12 @@ def test_create_lanes_for_large_collection(self, db: DatabaseTransactionFixture) db.session.delete(lane) # If there's an NYT Best Sellers integration and we create the lanes again... - integration, ignore = create( - db.session, - ExternalIntegration, - goal=ExternalIntegration.METADATA_GOAL, - protocol=ExternalIntegration.NYT, + nyt_protocol = MetadataRegistry().get_protocol(NYTBestSellerAPI) + assert nyt_protocol is not None + db.integration_configuration( + nyt_protocol, + goal=Goals.METADATA_GOAL, + password="foo", ) create_lanes_for_large_collection(db.session, db.default_library(), languages) @@ -553,18 +550,11 @@ def test_initialization(self, related_books_fixture: RelatedBooksFixture): # When NoveList is configured and recommendations are available, # a RecommendationLane will be included. - db.external_integration( - ExternalIntegration.NOVELIST, - goal=ExternalIntegration.METADATA_GOAL, - username="library", - password="sure", - libraries=[db.default_library()], - ) - mock_api = MockNoveListAPI(db.session) + mock_api = create_autospec(NoveListAPI) response = Metadata( related_books_fixture.edition.data_source, recommendations=[db.identifier()] ) - mock_api.setup_method(response) + mock_api.lookup.return_value = response result = RelatedBooksLane( db.default_library(), related_books_fixture.work, "", novelist_api=mock_api ) @@ -692,8 +682,8 @@ def generate_mock_api(self, lane_fixture: LaneFixture): source = DataSource.lookup(lane_fixture.db.session, DataSource.OVERDRIVE) metadata = Metadata(source) - mock_api = MockNoveListAPI(lane_fixture.db.session) - mock_api.setup_method(metadata) + mock_api = create_autospec(NoveListAPI) + mock_api.lookup.return_value = metadata return mock_api def test_modify_search_filter_hook(self, lane_fixture: LaneFixture): @@ -1050,7 +1040,7 @@ def test_works( lane = CrawlableCollectionBasedLane() lane.initialize([db.default_collection()]) search = external_search_fake_fixture.external_search - search.query_works = MagicMock(return_value=[]) # type: ignore [method-assign] + search.query_works = MagicMock(return_value=[]) lane.works( db.session, facets=CrawlableFacets.default(None), search_engine=search ) diff --git a/tests/api/test_odl.py b/tests/api/test_odl.py index 040a285c8..6e025bece 100644 --- a/tests/api/test_odl.py +++ b/tests/api/test_odl.py @@ -288,6 +288,23 @@ def test_checkin_cannot_return( odl_api_test_fixture.patron, "pin", odl_api_test_fixture.pool ) + def test_checkin_open_access( + self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture + ) -> None: + # Checking in an open-access book doesn't need to call out to the distributor API. + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + pool = oa_work.license_pools[0] + loan, ignore = pool.loan_to(odl_api_test_fixture.patron) + + # make sure that _checkin isn't called since it is not needed for an open access work + odl_api_test_fixture.api._checkin = MagicMock( + side_effect=Exception("Should not be called") + ) + + odl_api_test_fixture.api.checkin(odl_api_test_fixture.patron, "pin", pool) + def test_checkout_success( self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture ) -> None: @@ -321,6 +338,24 @@ def test_checkout_success( assert 5 == odl_api_test_fixture.pool.licenses_available assert 29 == odl_api_test_fixture.license.checkouts_left + def test_checkout_open_access( + self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture + ) -> None: + # This book is available to check out. + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + loan = odl_api_test_fixture.api.checkout( + odl_api_test_fixture.patron, "pin", oa_work.license_pools[0], None + ) + + assert loan.collection(db.session) == odl_api_test_fixture.collection + assert loan.identifier == oa_work.license_pools[0].identifier.identifier + assert loan.identifier_type == oa_work.license_pools[0].identifier.type + assert loan.start_date is None + assert loan.end_date is None + assert loan.external_identifier is None + def test_checkout_success_with_hold( self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture ) -> None: @@ -656,6 +691,42 @@ def test_fulfill_success( assert correct_link == fulfillment.content_link assert correct_type == fulfillment.content_type + def test_fulfill_open_access( + self, + odl_api_test_fixture: ODLAPITestFixture, + db: DatabaseTransactionFixture, + ) -> None: + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + pool = oa_work.license_pools[0] + loan, ignore = pool.loan_to(odl_api_test_fixture.patron) + + # If we can't find a delivery mechanism, we can't fulfill the loan. + pytest.raises( + CannotFulfill, + odl_api_test_fixture.api.fulfill, + odl_api_test_fixture.patron, + "pin", + pool, + MagicMock(spec=LicensePoolDeliveryMechanism), + ) + + lpdm = pool.delivery_mechanisms[0] + fulfillment = odl_api_test_fixture.api.fulfill( + odl_api_test_fixture.patron, "pin", pool, lpdm + ) + + assert odl_api_test_fixture.collection == fulfillment.collection(db.session) + assert ( + odl_api_test_fixture.pool.data_source.name == fulfillment.data_source_name + ) + assert fulfillment.identifier_type == pool.identifier.type + assert fulfillment.identifier == pool.identifier.identifier + assert fulfillment.content_expires is None + assert fulfillment.content_link == pool.open_access_download_url + assert fulfillment.content_type == lpdm.delivery_mechanism.content_type + def test_fulfill_cannot_fulfill( self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture ) -> None: @@ -1300,6 +1371,38 @@ def test_patron_activity_loan( assert odl_api_test_fixture.pool.identifier.identifier == activity[0].identifier odl_api_test_fixture.checkin(pool=pool2) + # Open access loans are included. + oa_work = db.work( + with_open_access_download=True, collection=odl_api_test_fixture.collection + ) + pool3 = oa_work.license_pools[0] + loan3, ignore = pool3.loan_to(odl_api_test_fixture.patron) + + activity = odl_api_test_fixture.api.patron_activity( + odl_api_test_fixture.patron, "pin" + ) + assert 2 == len(activity) + [l1, l2] = sorted(activity, key=lambda x: x.start_date) + + assert odl_api_test_fixture.collection == l1.collection(db.session) + assert odl_api_test_fixture.pool.data_source.name == l1.data_source_name + assert odl_api_test_fixture.pool.identifier.type == l1.identifier_type + assert odl_api_test_fixture.pool.identifier.identifier == l1.identifier + assert loan.start == l1.start_date + assert loan.end == l1.end_date + assert loan.external_identifier == l1.external_identifier + + assert odl_api_test_fixture.collection == l2.collection(db.session) + assert pool3.data_source.name == l2.data_source_name + assert pool3.identifier.type == l2.identifier_type + assert pool3.identifier.identifier == l2.identifier + assert loan3.start == l2.start_date + assert loan3.end == l2.end_date + assert loan3.external_identifier == l2.external_identifier + + # remove the open access loan + db.session.delete(loan3) + # One hold. other_patron = db.patron() odl_api_test_fixture.checkout(patron=other_patron, pool=pool2) diff --git a/tests/api/test_odl2.py b/tests/api/test_odl2.py index cbca67109..220dab6cf 100644 --- a/tests/api/test_odl2.py +++ b/tests/api/test_odl2.py @@ -41,7 +41,7 @@ class TestODL2Importer: def _get_delivery_mechanism_by_drm_scheme_and_content_type( delivery_mechanisms: list[LicensePoolDeliveryMechanism], content_type: str, - drm_scheme: str, + drm_scheme: str | None, ) -> DeliveryMechanism | None: """Find a license pool in the list by its identifier. @@ -327,6 +327,49 @@ def test_import_audiobook_no_streaming( ) assert lcp_delivery_mechanism is not None + @freeze_time("2016-01-01T00:00:00+00:00") + def test_import_open_access( + self, + odl2_importer: ODL2Importer, + api_odl2_files_fixture: ODL2APIFilesFixture, + ) -> None: + """ + Ensure that ODL2Importer2 correctly processes and imports a feed with an + open access book. + """ + feed = api_odl2_files_fixture.sample_text("oa-title.json") + imported_editions, pools, works, failures = odl2_importer.import_from_feed(feed) + + assert isinstance(imported_editions, list) + assert 1 == len(imported_editions) + + [edition] = imported_editions + assert isinstance(edition, Edition) + assert ( + edition.primary_identifier.identifier + == "https://www.feedbooks.com/book/7256" + ) + assert edition.primary_identifier.type == "URI" + assert edition.medium == EditionConstants.BOOK_MEDIUM + + # Make sure that license pools have correct configuration + assert isinstance(pools, list) + assert 1 == len(pools) + + [license_pool] = pools + assert license_pool.open_access is True + + assert 1 == len(license_pool.delivery_mechanisms) + + oa_ebook_delivery_mechanism = ( + self._get_delivery_mechanism_by_drm_scheme_and_content_type( + license_pool.delivery_mechanisms, + MediaTypes.EPUB_MEDIA_TYPE, + None, + ) + ) + assert oa_ebook_delivery_mechanism is not None + class TestODL2API: def test_loan_limit(self, odl2_api_test_fixture: ODL2APITestFixture): diff --git a/tests/api/test_scripts.py b/tests/api/test_scripts.py index d172241f6..6a7a40fca 100644 --- a/tests/api/test_scripts.py +++ b/tests/api/test_scripts.py @@ -14,8 +14,7 @@ from alembic.util import CommandError from api.adobe_vendor_id import AuthdataUtility from api.config import Configuration -from api.novelist import NoveListAPI -from core.external_search import ExternalSearchIndex +from api.metadata.novelist import NoveListAPI from core.integration.goals import Goals from core.marc import MARCExporter, MarcExporterLibrarySettings, MarcExporterSettings from core.model import ( @@ -41,7 +40,7 @@ NovelistSnapshotScript, ) from tests.fixtures.library import LibraryFixture -from tests.fixtures.search import EndToEndSearchFixture +from tests.fixtures.search import EndToEndSearchFixture, ExternalSearchFixtureFake if TYPE_CHECKING: from tests.fixtures.authenticator import SimpleAuthIntegrationFixture @@ -615,15 +614,11 @@ def test_initialize_database(self, db: DatabaseTransactionFixture): with patch( "scripts.SessionManager", autospec=SessionManager ) as session_manager: - with patch( - "scripts.ExternalSearchIndex", autospec=ExternalSearchIndex - ) as search_index: - with patch("scripts.command") as alemic_command: - script.initialize_database(mock_db) + with patch("scripts.command") as alemic_command: + script.initialize_database(mock_db) session_manager.initialize_data.assert_called_once() session_manager.initialize_schema.assert_called_once() - search_index.assert_called_once() alemic_command.stamp.assert_called_once() def test_migrate_database(self, db: DatabaseTransactionFixture): @@ -645,67 +640,59 @@ def test_find_alembic_ini(self, db: DatabaseTransactionFixture): assert conf.attributes["connection"] == mock_connection.engine assert conf.attributes["configure_logger"] is False - def test_initialize_search_indexes( - self, end_to_end_search_fixture: EndToEndSearchFixture + def test_initialize_search_indexes_mocked( + self, + external_search_fake_fixture: ExternalSearchFixtureFake, + caplog: LogCaptureFixture, ): - db = end_to_end_search_fixture.db - search = end_to_end_search_fixture.external_search_index - base_name = search._revision_base_name + caplog.set_level(logging.WARNING) + script = InstanceInitializationScript() - _mockable_search = ExternalSearchIndex(db.session) - _mockable_search.start_migration = MagicMock() # type: ignore [method-assign] - _mockable_search.search_service = MagicMock() # type: ignore [method-assign] - _mockable_search.log = MagicMock() + search_service = external_search_fake_fixture.external_search + search_service.start_migration = MagicMock() + search_service.search_service = MagicMock() - def mockable_search(*args): - return _mockable_search + # To fake "no migration is available", mock all the values + search_service.start_migration.return_value = None + search_service.search_service().is_pointer_empty.return_value = True - # Initially this should not exist, if InstanceInit has not been run - assert search.search_service().read_pointer() == None - - with patch("scripts.ExternalSearchIndex", new=mockable_search): - # To fake "no migration is available", mock all the values - - _mockable_search.start_migration.return_value = None - _mockable_search.search_service().is_pointer_empty.return_value = True - # Migration should fail - assert script.initialize_search_indexes(db.session) == False - # Logs were emitted - assert _mockable_search.log.warning.call_count == 1 - assert ( - "no migration was available" - in _mockable_search.log.warning.call_args[0][0] - ) + # Migration should fail + assert script.initialize_search_indexes() is False + + # Logs were emitted + record = caplog.records.pop() + assert "WARNING" in record.levelname + assert "no migration was available" in record.message - _mockable_search.search_service.reset_mock() - _mockable_search.start_migration.reset_mock() - _mockable_search.log.reset_mock() + search_service.search_service.reset_mock() + search_service.start_migration.reset_mock() - # In case there is no need for a migration, read pointer exists as a non-empty pointer - _mockable_search.search_service().is_pointer_empty.return_value = False - # Initialization should pass, as a no-op - assert script.initialize_search_indexes(db.session) == True - assert _mockable_search.start_migration.call_count == 0 + # In case there is no need for a migration, read pointer exists as a non-empty pointer + search_service.search_service().is_pointer_empty.return_value = False + + # Initialization should pass, as a no-op + assert script.initialize_search_indexes() is True + assert search_service.start_migration.call_count == 0 + + def test_initialize_search_indexes( + self, end_to_end_search_fixture: EndToEndSearchFixture + ): + search = end_to_end_search_fixture.external_search_index + base_name = end_to_end_search_fixture.external_search.service.base_revision_name + script = InstanceInitializationScript() + + # Initially this should not exist, if InstanceInit has not been run + assert search.search_service().read_pointer() is None # Initialization should work now - assert script.initialize_search_indexes(db.session) == True + assert script.initialize_search_indexes() is True # Then we have the latest version index assert ( search.search_service().read_pointer() == search._revision.name_for_index(base_name) ) - def test_initialize_search_indexes_no_integration( - self, db: DatabaseTransactionFixture - ): - script = InstanceInitializationScript() - script._log = MagicMock() - # No integration mean no migration - assert script.initialize_search_indexes(db.session) == False - assert script._log.error.call_count == 2 - assert "No search integration" in script._log.error.call_args[0][0] - class TestLanguageListScript: def test_languages(self, db: DatabaseTransactionFixture): diff --git a/tests/api/test_selftest.py b/tests/api/test_selftest.py index b7eb79f42..a3acd2b40 100644 --- a/tests/api/test_selftest.py +++ b/tests/api/test_selftest.py @@ -11,18 +11,18 @@ from api.authentication.basic import BasicAuthenticationProvider from api.circulation import CirculationAPI -from api.selftest import HasCollectionSelfTests, HasSelfTests, SelfTestResult +from api.selftest import HasCollectionSelfTests, HasPatronSelfTests, SelfTestResult from core.exceptions import IntegrationException from core.model import Patron from core.scripts import RunSelfTestsScript from core.util.problem_detail import ProblemDetail +from tests.fixtures.authenticator import SimpleAuthIntegrationFixture if TYPE_CHECKING: - from tests.fixtures.authenticator import SimpleAuthIntegrationFixture from tests.fixtures.database import DatabaseTransactionFixture -class TestHasSelfTests: +class TestHasPatronSelfTests: def test__determine_self_test_patron( self, db: DatabaseTransactionFixture, @@ -35,8 +35,8 @@ def test__determine_self_test_patron( - raises the expected _NoValidLibrarySelfTestPatron exception. """ - test_patron_lookup_method = HasSelfTests._determine_self_test_patron - test_patron_lookup_exception = HasSelfTests._NoValidLibrarySelfTestPatron + test_patron_lookup_method = HasPatronSelfTests._determine_self_test_patron + test_patron_lookup_exception = HasPatronSelfTests._NoValidLibrarySelfTestPatron # This library has no patron authentication integration configured. library_without_default_patron = db.library() @@ -101,7 +101,7 @@ def test_default_patrons( default_patrons() method finds the default Patron for every Library associated with a given Collection. """ - h = HasSelfTests + h = HasPatronSelfTests # This collection is not in any libraries, so there's no way # to test it. diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 1a48ae45f..bb6feff57 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -3,7 +3,6 @@ pytest_plugins = [ "tests.fixtures.announcements", "tests.fixtures.csv_files", - "tests.fixtures.container", "tests.fixtures.database", "tests.fixtures.library", "tests.fixtures.opds2_files", diff --git a/tests/core/models/test_collection.py b/tests/core/models/test_collection.py index a33602ab9..a8f887a3d 100644 --- a/tests/core/models/test_collection.py +++ b/tests/core/models/test_collection.py @@ -19,6 +19,7 @@ from core.model.licensing import Hold, License, LicensePool, Loan from core.model.work import Work from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.services import ServicesFixture class ExampleCollectionFixture: @@ -551,7 +552,11 @@ def expect(qu, works): setting.value = json.dumps([DataSource.OVERDRIVE, DataSource.FEEDBOOKS]) expect(qu, [overdrive_ebook]) - def test_delete(self, example_collection_fixture: ExampleCollectionFixture): + def test_delete( + self, + example_collection_fixture: ExampleCollectionFixture, + services_fixture_wired: ServicesFixture, + ): """Verify that Collection.delete will only operate on collections flagged for deletion, and that deletion cascades to all relevant related database objects. @@ -649,13 +654,16 @@ def test_delete(self, example_collection_fixture: ExampleCollectionFixture): index.remove_work.assert_called_once_with(work) # If no search_index is passed into delete() (the default behavior), - # we try to instantiate the normal ExternalSearchIndex object. Since - # no search index is configured, this will raise an exception -- but - # delete() will catch the exception and carry out the delete, - # without trying to delete any Works from the search index. + # then it will use the search index injected from the services + # container. collection2.marked_for_deletion = True collection2.delete() + # The search index was injected and told to remove the second work. + services_fixture_wired.search_fixture.index_mock.remove_work.assert_called_once_with( + work2 + ) + # We've now deleted every LicensePool created for this test. assert 0 == db.session.query(LicensePool).count() assert [] == work2.license_pools diff --git a/tests/core/models/test_work.py b/tests/core/models/test_work.py index f2a12c271..f8408de5a 100644 --- a/tests/core/models/test_work.py +++ b/tests/core/models/test_work.py @@ -260,7 +260,7 @@ def test_calculate_presentation( assert (utc_now() - work.last_update_time) < datetime.timedelta(seconds=2) # type: ignore[unreachable] # The index has not been updated. - assert [] == external_search_fake_fixture.search.documents_all() + assert [] == external_search_fake_fixture.service.documents_all() # The Work now has a complete set of WorkCoverageRecords # associated with it, reflecting all the operations that @@ -480,7 +480,7 @@ def test_set_presentation_ready_based_on_content( assert True == work.presentation_ready # The work has not been added to the search index. - assert [] == external_search_fake_fixture.search.documents_all() + assert [] == external_search_fake_fixture.service.documents_all() # But the work of adding it to the search engine has been # registered. @@ -1606,7 +1606,7 @@ def mock_reset_coverage(operation): # The work was not added to the search index when we called # external_index_needs_updating. That happens later, when the # WorkCoverageRecord is processed. - assert [] == external_search_fake_fixture.search.documents_all() + assert [] == external_search_fake_fixture.service.documents_all() def test_for_unchecked_subjects(self, db: DatabaseTransactionFixture): w1 = db.work(with_license_pool=True) diff --git a/tests/core/search/test_migration_states.py b/tests/core/search/test_migration_states.py index aa3ba92c0..f9c6f0fe1 100644 --- a/tests/core/search/test_migration_states.py +++ b/tests/core/search/test_migration_states.py @@ -14,8 +14,9 @@ import pytest -from core.external_search import ExternalSearchIndex, SearchIndexCoverageProvider +from core.external_search import ExternalSearchIndex from core.scripts import RunWorkCoverageProviderScript +from core.search.coverage_provider import SearchIndexCoverageProvider from core.search.document import SearchMappingDocument from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory @@ -29,20 +30,19 @@ def test_initial_migration_case( ): fx = external_search_fixture db = fx.db + index = fx.index + service = fx.service # Ensure we are in the initial state, no test indices and pointer available - prefix = fx.integration.setting( - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY - ).value - all_indices = fx.search.indices.get("*") + prefix = fx.search_config.index_prefix + all_indices = fx.client.indices.get("*") for index_name in all_indices.keys(): - assert prefix not in index_name - - client = ExternalSearchIndex(db.session) + if prefix in index_name: + fx.client.indices.delete(index_name) # We cannot make any requests before we intitialize with pytest.raises(Exception) as raised: - client.query_works("") + index.query_works("") assert "index_not_found" in str(raised.value) # When a new sytem comes up the first code to run is the InstanceInitailization script @@ -50,22 +50,22 @@ def test_initial_migration_case( InstanceInitializationScript().initialize(db.session.connection()) # Ensure we have created the index and pointers - new_index_name = client._revision.name_for_index(client._revision_base_name) - empty_index_name = client.search_service()._empty(client._revision_base_name) # type: ignore [attr-defined] - all_indices = fx.search.indices.get("*") + new_index_name = index._revision.name_for_index(prefix) + empty_index_name = service._empty(prefix) + all_indices = fx.client.indices.get("*") assert prefix in new_index_name assert new_index_name in all_indices.keys() assert empty_index_name in all_indices.keys() - assert fx.search.indices.exists_alias( - client._search_read_pointer, index=new_index_name + assert fx.client.indices.exists_alias( + index._search_read_pointer, index=new_index_name ) - assert fx.search.indices.exists_alias( - client._search_write_pointer, index=new_index_name + assert fx.client.indices.exists_alias( + index._search_write_pointer, index=new_index_name ) # The same client should work without issue once the pointers are setup - assert client.query_works("").hits == [] + assert index.query_works("").hits == [] def test_migration_case(self, external_search_fixture: ExternalSearchFixture): fx = external_search_fixture @@ -85,23 +85,24 @@ def mapping_document(self) -> SearchMappingDocument: return SearchMappingDocument() client = ExternalSearchIndex( - db.session, + fx.search_container.service(), revision_directory=SearchRevisionDirectory( {MOCK_VERSION: MockSchema(MOCK_VERSION)} ), ) + # The search client works just fine assert client.query_works("") is not None receiver = client.start_updating_search_documents() receiver.add_documents([{"work_id": 123}]) receiver.finish() - mock_index_name = client._revision.name_for_index(client._revision_base_name) + mock_index_name = client._revision.name_for_index(fx.service.base_revision_name) assert str(MOCK_VERSION) in mock_index_name # The mock index does not exist yet with pytest.raises(Exception) as raised: - fx.search.indices.get(mock_index_name) + fx.client.indices.get(mock_index_name) assert "index_not_found" in str(raised.value) # This should run the migration @@ -110,10 +111,10 @@ def mapping_document(self) -> SearchMappingDocument: ).run() # The new version is created, and the aliases point to the right index - assert fx.search.indices.get(mock_index_name) is not None - assert mock_index_name in fx.search.indices.get_alias( + assert fx.client.indices.get(mock_index_name) is not None + assert mock_index_name in fx.client.indices.get_alias( name=client._search_read_pointer ) - assert mock_index_name in fx.search.indices.get_alias( + assert mock_index_name in fx.client.indices.get_alias( name=client._search_write_pointer ) diff --git a/tests/core/search/test_service.py b/tests/core/search/test_service.py index 4192e5199..59c9ad6c7 100644 --- a/tests/core/search/test_service.py +++ b/tests/core/search/test_service.py @@ -30,7 +30,7 @@ def test_create_empty_idempotent( self, external_search_fixture: ExternalSearchFixture ): """Creating the empty index is idempotent.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) service.create_empty_index() # Log the index so that the fixture cleans it up afterward. @@ -38,7 +38,7 @@ def test_create_empty_idempotent( service.create_empty_index() - indices = external_search_fixture.search.indices.client.indices + indices = external_search_fixture.client.indices.client.indices assert indices is not None assert indices.exists("base-empty") @@ -46,7 +46,7 @@ def test_create_index_idempotent( self, external_search_fixture: ExternalSearchFixture ): """Creating any index is idempotent.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) service.index_create(revision) service.index_create(revision) @@ -54,23 +54,23 @@ def test_create_index_idempotent( # Log the index so that the fixture cleans it up afterward. external_search_fixture.record_index("base-v23") - indices = external_search_fixture.search.indices.client.indices + indices = external_search_fixture.client.indices.client.indices assert indices is not None assert indices.exists(revision.name_for_index("base")) def test_read_pointer_none(self, external_search_fixture: ExternalSearchFixture): """The read pointer is initially unset.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) assert None == service.read_pointer() def test_write_pointer_none(self, external_search_fixture: ExternalSearchFixture): """The write pointer is initially unset.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) assert None == service.write_pointer() def test_read_pointer_set(self, external_search_fixture: ExternalSearchFixture): """Setting the read pointer works.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) service.index_create(revision) @@ -84,7 +84,7 @@ def test_read_pointer_set_empty( self, external_search_fixture: ExternalSearchFixture ): """Setting the read pointer to the empty index works.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) service.create_empty_index() # Log the index so that the fixture cleans it up afterward. @@ -95,7 +95,7 @@ def test_read_pointer_set_empty( def test_write_pointer_set(self, external_search_fixture: ExternalSearchFixture): """Setting the write pointer works.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) service.index_create(revision) @@ -112,7 +112,7 @@ def test_populate_index_idempotent( self, external_search_fixture: ExternalSearchFixture ): """Populating an index is idempotent.""" - service = SearchServiceOpensearch1(external_search_fixture.search, BASE_NAME) + service = SearchServiceOpensearch1(external_search_fixture.client, BASE_NAME) revision = BasicMutableRevision(23) mappings = revision.mapping_document() @@ -150,7 +150,7 @@ def test_populate_index_idempotent( service.index_submit_documents("base-v23", documents) service.index_submit_documents("base-v23", documents) - indices = external_search_fixture.search.indices.client.indices + indices = external_search_fixture.client.indices.client.indices assert indices is not None assert indices.exists(revision.name_for_index("base")) assert indices.get(revision.name_for_index("base"))["base-v23"]["mappings"] == { diff --git a/tests/core/test_external_search.py b/tests/core/test_external_search.py index 8d57cc4dd..a9f57e98b 100644 --- a/tests/core/test_external_search.py +++ b/tests/core/test_external_search.py @@ -34,7 +34,6 @@ QueryParseException, QueryParser, SearchBase, - SearchIndexCoverageProvider, SortKeyPagination, WorkSearchResult, ) @@ -56,6 +55,7 @@ from core.model.work import Work from core.problem_details import INVALID_INPUT from core.scripts import RunWorkCoverageProviderScript +from core.search.coverage_provider import SearchIndexCoverageProvider from core.search.document import SearchMappingDocument from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory @@ -132,85 +132,6 @@ def query_works_multi(self, queries, debug=False): assert pagination.offset == default.offset assert pagination.size == default.size - def test__run_self_tests(self, end_to_end_search_fixture: EndToEndSearchFixture): - transaction = end_to_end_search_fixture.db - session = transaction.session - index = end_to_end_search_fixture.external_search_index - - # Intrusively set the search term to something useful. - index._test_search_term = "How To Search" - - # Start with an up-to-date but empty index. - index.start_migration().finish() - - # First, see what happens when the search returns no results. - test_results = [x for x in index._run_self_tests(session)] - - assert "Search results for 'How To Search':" == test_results[0].name - assert True == test_results[0].success - assert [] == test_results[0].result - - assert "Search document for 'How To Search':" == test_results[1].name - assert True == test_results[1].success - assert {} != test_results[1].result - - assert "Raw search results for 'How To Search':" == test_results[2].name - assert True == test_results[2].success - assert [] == test_results[2].result - - assert ( - "Total number of search results for 'How To Search':" - == test_results[3].name - ) - assert True == test_results[3].success - assert "0" == test_results[3].result - - assert "Total number of documents in this search index:" == test_results[4].name - assert True == test_results[4].success - assert "0" == test_results[4].result - - assert "Total number of documents per collection:" == test_results[5].name - assert True == test_results[5].success - assert "{}" == test_results[5].result - - # Set up the search index so it will return a result. - work = end_to_end_search_fixture.external_search.default_work( - title="How To Search" - ) - work.presentation_ready = True - work.presentation_edition.subtitle = "How To Search" - work.presentation_edition.series = "Classics" - work.summary_text = "How To Search!" - work.presentation_edition.publisher = "Project Gutenberg" - work.last_update_time = datetime_utc(2019, 1, 1) - work.license_pools[0].licenses_available = 100000 - - docs = index.start_updating_search_documents() - docs.add_documents(index.create_search_documents_from_works([work])) - docs.finish() - - test_results = [x for x in index._run_self_tests(session)] - - assert "Search results for 'How To Search':" == test_results[0].name - assert True == test_results[0].success - assert [f"How To Search ({work.author})"] == test_results[0].result - - assert ( - "Total number of search results for 'How To Search':" - == test_results[3].name - ) - assert True == test_results[3].success - assert "1" == test_results[3].result - - assert "Total number of documents in this search index:" == test_results[4].name - assert True == test_results[4].success - assert "1" == test_results[4].result - - assert "Total number of documents per collection:" == test_results[5].name - assert True == test_results[5].success - result = json.loads(test_results[5].result) - assert {"Default Collection": 1} == result - class TestSearchV5: def test_character_filters(self): @@ -979,7 +900,10 @@ def pages(worklist): while pagination: pages.append( worklist.works( - session, facets, pagination, fixture.external_search_index + session, + facets, + pagination, + search_engine=fixture.external_search_index, ) ) pagination = pagination.next_page @@ -1107,14 +1031,14 @@ def pages(worklist): expect([data.lincoln_vampire], "fantasy") def test_remove_work(self, end_to_end_search_fixture: EndToEndSearchFixture): - search = end_to_end_search_fixture.external_search.search + client = end_to_end_search_fixture.external_search.client data = self._populate_works(end_to_end_search_fixture) end_to_end_search_fixture.populate_search_index() end_to_end_search_fixture.external_search_index.remove_work(data.moby_dick) end_to_end_search_fixture.external_search_index.remove_work(data.moby_duck) # Immediately querying never works, the search index needs to refresh its cache/index/data - search.indices.refresh() + client.indices.refresh() end_to_end_search_fixture.expect_results([], "Moby") @@ -2116,7 +2040,11 @@ def assert_featured(description, worklist, facets, expect): # available books will show up before all of the unavailable # books. only_availability_matters = worklist.works( - session, facets, None, fixture.external_search_index, debug=True + session, + facets, + None, + search_engine=fixture.external_search_index, + debug=True, ) assert 5 == len(only_availability_matters) last_two = only_availability_matters[-2:] @@ -4733,7 +4661,9 @@ def test_works_not_presentation_ready_kept_in_index( # All three works were inserted into the index, even the one # that's not presentation-ready. ids = set( - map(lambda d: d["_id"], external_search_fake_fixture.search.documents_all()) + map( + lambda d: d["_id"], external_search_fake_fixture.service.documents_all() + ) ) assert {w1.id, w2.id, w3.id} == ids @@ -4746,7 +4676,9 @@ def test_works_not_presentation_ready_kept_in_index( ) docs.finish() assert {w1.id, w2.id, w3.id} == set( - map(lambda d: d["_id"], external_search_fake_fixture.search.documents_all()) + map( + lambda d: d["_id"], external_search_fake_fixture.service.documents_all() + ) ) assert [] == failures @@ -4760,7 +4692,7 @@ def test_search_connection_timeout( external_search_fake_fixture.db, ) - search.search.set_failing_mode( + search.service.set_failing_mode( mode=SearchServiceFailureMode.FAIL_INDEXING_DOCUMENTS_TIMEOUT ) work = transaction.work() @@ -4776,7 +4708,7 @@ def test_search_connection_timeout( # Submissions are not retried by the base service assert [work.id] == [ - docs["_id"] for docs in search.search.document_submission_attempts + docs["_id"] for docs in search.service.document_submission_attempts ] def test_search_single_document_error( @@ -4787,7 +4719,7 @@ def test_search_single_document_error( external_search_fake_fixture.db, ) - search.search.set_failing_mode( + search.service.set_failing_mode( mode=SearchServiceFailureMode.FAIL_INDEXING_DOCUMENTS ) work = transaction.work() @@ -4803,7 +4735,7 @@ def test_search_single_document_error( # Submissions are not retried by the base service assert [work.id] == [ - docs["_id"] for docs in search.search.document_submission_attempts + docs["_id"] for docs in search.service.document_submission_attempts ] @@ -4952,7 +4884,7 @@ def test_success( assert [work] == results # The work was added to the search index. - search_service = external_search_fake_fixture.search + search_service = external_search_fake_fixture.service assert 1 == len(search_service.documents_all()) def test_failure( @@ -4963,7 +4895,7 @@ def test_failure( work = db.work() work.set_presentation_ready() index = external_search_fake_fixture.external_search - external_search_fake_fixture.search.set_failing_mode( + external_search_fake_fixture.service.set_failing_mode( SearchServiceFailureMode.FAIL_INDEXING_DOCUMENTS ) diff --git a/tests/core/test_lane.py b/tests/core/test_lane.py index 8a52841b5..f426aba98 100644 --- a/tests/core/test_lane.py +++ b/tests/core/test_lane.py @@ -1,6 +1,7 @@ import datetime import logging import random +from typing import cast from unittest.mock import MagicMock, call import pytest @@ -15,7 +16,7 @@ EntryPoint, EverythingEntryPoint, ) -from core.external_search import Filter, WorkSearchResult, mock_search_index +from core.external_search import ExternalSearchIndex, Filter, WorkSearchResult from core.lane import ( DatabaseBackedFacets, DatabaseBackedWorkList, @@ -2384,7 +2385,11 @@ def works_for_hits(self, _db, work_ids, facets=None): # Ask the WorkList for a page of works, using the search index # to drive the query instead of the database. result = wl.works( - db.session, facets, mock_pagination, search_client, mock_debug + db.session, + facets, + mock_pagination, + search_engine=cast(ExternalSearchIndex, search_client), + debug=mock_debug, ) # MockSearchClient.query_works was used to grab a list of work @@ -3595,8 +3600,7 @@ def count_works(self, filter): fiction = db.lane(display_name="Fiction", fiction=True) fiction.size = 44 fiction.size_by_entrypoint = {"Nonexistent entrypoint": 33} - with mock_search_index(search_engine): - fiction.update_size(db.session) + fiction.update_size(db.session, search_engine=search_engine) # The lane size is also calculated individually for every # enabled entry point. EverythingEntryPoint is used for the @@ -4266,7 +4270,6 @@ def test_groups( fixture.external_search.db, fixture.external_search.db.session, ) - fixture.external_search_index.start_migration().finish() # type: ignore [union-attr] # Tell the fixture to call our populate_works method. # In this library, the groups feed includes at most two books diff --git a/tests/core/test_local_analytics_provider.py b/tests/core/test_local_analytics_provider.py index 7b54da8f5..0ac174b27 100644 --- a/tests/core/test_local_analytics_provider.py +++ b/tests/core/test_local_analytics_provider.py @@ -10,7 +10,6 @@ if TYPE_CHECKING: from tests.fixtures.database import DatabaseTransactionFixture - from tests.fixtures.services import MockServicesFixture class LocalAnalyticsProviderFixture: @@ -21,18 +20,16 @@ class LocalAnalyticsProviderFixture: def __init__( self, transaction: DatabaseTransactionFixture, - mock_services_fixture: MockServicesFixture, ): self.transaction = transaction - self.services = mock_services_fixture.services self.la = LocalAnalyticsProvider() @pytest.fixture() def local_analytics_provider_fixture( - db: DatabaseTransactionFixture, mock_services_fixture: MockServicesFixture + db: DatabaseTransactionFixture, ) -> LocalAnalyticsProviderFixture: - return LocalAnalyticsProviderFixture(db, mock_services_fixture) + return LocalAnalyticsProviderFixture(db) class TestLocalAnalyticsProvider: diff --git a/tests/core/test_s3_analytics_provider.py b/tests/core/test_s3_analytics_provider.py index bcb64444d..f95725918 100644 --- a/tests/core/test_s3_analytics_provider.py +++ b/tests/core/test_s3_analytics_provider.py @@ -3,7 +3,7 @@ import datetime import json from typing import TYPE_CHECKING -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import pytest @@ -11,29 +11,25 @@ from core.classifier import Classifier from core.config import CannotLoadConfiguration from core.model import CirculationEvent, DataSource, MediaTypes +from core.service.storage.s3 import S3Service if TYPE_CHECKING: from tests.fixtures.database import DatabaseTransactionFixture - from tests.fixtures.services import MockServicesFixture class S3AnalyticsFixture: - def __init__( - self, db: DatabaseTransactionFixture, services_fixture: MockServicesFixture - ) -> None: + def __init__(self, db: DatabaseTransactionFixture) -> None: self.db = db - self.services = services_fixture.services - self.analytics_storage = services_fixture.storage.analytics + + self.analytics_storage = create_autospec(S3Service) self.analytics_provider = S3AnalyticsProvider( - services_fixture.services.storage.analytics(), + self.analytics_storage, ) @pytest.fixture(scope="function") -def s3_analytics_fixture( - db: DatabaseTransactionFixture, mock_services_fixture: MockServicesFixture -): - return S3AnalyticsFixture(db, mock_services_fixture) +def s3_analytics_fixture(db: DatabaseTransactionFixture): + return S3AnalyticsFixture(db) class TestS3AnalyticsProvider: @@ -52,13 +48,8 @@ def timestamp_to_string(timestamp): def test_exception_is_raised_when_no_analytics_bucket_configured( self, s3_analytics_fixture: S3AnalyticsFixture ): - # The services container returns None when there is no analytics storage service configured, - # so we override the analytics storage service with None to simulate this situation. - s3_analytics_fixture.services.storage.analytics.override(None) - - provider = S3AnalyticsProvider( - s3_analytics_fixture.services.storage.analytics() - ) + # The services container returns None when there is no analytics storage service configured + provider = S3AnalyticsProvider(None) # Act, Assert with pytest.raises(CannotLoadConfiguration): diff --git a/tests/core/test_scripts.py b/tests/core/test_scripts.py index 18344d44b..10213984c 100644 --- a/tests/core/test_scripts.py +++ b/tests/core/test_scripts.py @@ -13,7 +13,7 @@ from api.lanes import create_default_lanes from core.classifier import Classifier -from core.config import CannotLoadConfiguration, Configuration, ConfigurationConstants +from core.config import Configuration, ConfigurationConstants from core.external_search import ExternalSearchIndex, Filter from core.lane import Lane, WorkList from core.metadata_layer import TimestampData @@ -90,6 +90,7 @@ ) from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.search import EndToEndSearchFixture, ExternalSearchFixtureFake +from tests.fixtures.services import ServicesFixture class TestScript: @@ -1608,23 +1609,6 @@ def out(self, s, *args): class TestWhereAreMyBooksScript: - def test_no_search_integration(self, db: DatabaseTransactionFixture): - # We can't even get started without a working search integration. - - # We'll also test the out() method by mocking the script's - # standard output and using the normal out() implementation. - # In other tests, which have more complicated output, we mock - # out(), so this verifies that output actually gets written - # out. - output = StringIO() - pytest.raises( - CannotLoadConfiguration, WhereAreMyBooksScript, db.session, output=output - ) - assert ( - "Here's your problem: the search integration is missing or misconfigured.\n" - == output.getvalue() - ) - @pytest.mark.skip( reason="This test currently freezes inside pytest and has to be killed with SIGKILL." ) @@ -2018,7 +2002,9 @@ def test_do_run( # The mock methods were called with the values we expect. assert {work.id, work2.id} == set( - map(lambda d: d["_id"], external_search_fake_fixture.search.documents_all()) + map( + lambda d: d["_id"], external_search_fake_fixture.service.documents_all() + ) ) # The script returned a list containing a single @@ -2286,38 +2272,33 @@ def test_process_custom_list( assert custom_list.auto_update_last_update == frozen_time.time_to_freeze assert custom_list1.auto_update_last_update == frozen_time.time_to_freeze - def test_search_facets(self, end_to_end_search_fixture: EndToEndSearchFixture): - with patch("core.query.customlist.ExternalSearchIndex") as mock_index: - fixture = end_to_end_search_fixture - db, session = ( - fixture.external_search.db, - fixture.external_search.db.session, - ) - data = self._populate_works(fixture) - fixture.populate_search_index() + def test_search_facets( + self, db: DatabaseTransactionFixture, services_fixture_wired: ServicesFixture + ): + mock_index = services_fixture_wired.search_fixture.index_mock - last_updated = datetime.datetime.now() - datetime.timedelta(hours=1) - custom_list, _ = db.customlist() - custom_list.library = db.default_library() - custom_list.auto_update_enabled = True - custom_list.auto_update_query = json.dumps( - dict(query=dict(key="title", value="Populated Book")) - ) - custom_list.auto_update_facets = json.dumps( - dict(order="title", languages="fr", media=["book", "audio"]) - ) - custom_list.auto_update_last_update = last_updated + last_updated = datetime.datetime.now() - datetime.timedelta(hours=1) + custom_list, _ = db.customlist() + custom_list.library = db.default_library() + custom_list.auto_update_enabled = True + custom_list.auto_update_query = json.dumps( + dict(query=dict(key="title", value="Populated Book")) + ) + custom_list.auto_update_facets = json.dumps( + dict(order="title", languages="fr", media=["book", "audio"]) + ) + custom_list.auto_update_last_update = last_updated - script = CustomListUpdateEntriesScript(session) - script.process_custom_list(custom_list) + script = CustomListUpdateEntriesScript(db.session) + script.process_custom_list(custom_list) - assert mock_index().query_works.call_count == 1 - filter: Filter = mock_index().query_works.call_args_list[0][0][1] - assert filter.sort_order[0] == { - "sort_title": "asc" - } # since we asked for title ordering this should come up first - assert filter.languages == ["fr"] - assert filter.media == ["book", "audio"] + assert mock_index.query_works.call_count == 1 + filter: Filter = mock_index.query_works.call_args_list[0][0][1] + assert filter.sort_order[0] == { + "sort_title": "asc" + } # since we asked for title ordering this should come up first + assert filter.languages == ["fr"] + assert filter.media == ["book", "audio"] @freeze_time("2022-01-01", as_kwarg="frozen_time") def test_no_last_update( diff --git a/tests/core/test_selftest.py b/tests/core/test_selftest.py index 49ea4cf14..7090fb880 100644 --- a/tests/core/test_selftest.py +++ b/tests/core/test_selftest.py @@ -7,11 +7,13 @@ import datetime from collections.abc import Generator -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec +from _pytest.monkeypatch import MonkeyPatch from sqlalchemy.orm import Session -from core.model import ExternalIntegration +from core.integration.goals import Goals +from core.model import IntegrationConfiguration from core.selftest import HasSelfTests, SelfTestResult from core.util.datetime_helpers import utc_now from core.util.http import IntegrationException @@ -93,60 +95,53 @@ def test_repr_failure(self): class MockSelfTest(HasSelfTests): + _integration: IntegrationConfiguration | None = None + + def __init__(self, *args, **kwargs): + self.called_with_args = args + self.called_with_kwargs = kwargs + + def integration(self, _db: Session) -> IntegrationConfiguration | None: + return self._integration + def _run_self_tests(self, _db: Session) -> Generator[SelfTestResult, None, None]: - raise Exception("I don't work!") + raise Exception("oh no") class TestHasSelfTests: - def test_run_self_tests(self, db: DatabaseTransactionFixture): + def test_run_self_tests( + self, db: DatabaseTransactionFixture, monkeypatch: MonkeyPatch + ): """See what might happen when run_self_tests tries to instantiate an object and run its self-tests. """ - - class Tester(HasSelfTests): - integration: ExternalIntegration | None - - def __init__(self, extra_arg=None): - """This constructor works.""" - self.invoked_with = extra_arg - - @classmethod - def good_alternate_constructor(self, another_extra_arg=None): - """This alternate constructor works.""" - tester = Tester() - tester.another_extra_arg = another_extra_arg - return tester - - @classmethod - def bad_alternate_constructor(self): - """This constructor doesn't work.""" - raise Exception("I don't work!") - - def external_integration(self, _db): - """This integration will be used to store the test results.""" - return self.integration - - def _run_self_tests(self, _db): - self._run_self_tests_called_with = _db - return [SelfTestResult("a test result")] - mock_db = MagicMock(spec=Session) # This integration will be used to store the test results. - integration = db.external_integration(db.fresh_str()) - Tester.integration = integration + integration = db.integration_configuration( + protocol="test", goal=Goals.PATRON_AUTH_GOAL + ) # By default, the default constructor is instantiated and its # _run_self_tests method is called. - data, [setup, test] = Tester.run_self_tests(mock_db, extra_arg="a value") - assert mock_db == setup.result._run_self_tests_called_with + mock__run_self_tests = create_autospec(MockSelfTest._run_self_tests) + mock__run_self_tests.return_value = [SelfTestResult("a test result")] + monkeypatch.setattr(MockSelfTest, "_run_self_tests", mock__run_self_tests) + monkeypatch.setattr(MockSelfTest, "_integration", integration) + + data, [setup, test] = MockSelfTest.run_self_tests(mock_db, extra_arg="a value") + assert mock__run_self_tests.call_count == 1 + assert isinstance(mock__run_self_tests.call_args.args[0], MockSelfTest) + assert mock__run_self_tests.call_args.args[1] == mock_db # There are two results -- `setup` from the initial setup # and `test` from the _run_self_tests call. - assert "Initial setup." == setup.name - assert True == setup.success - assert "a value" == setup.result.invoked_with - assert "a test result" == test.name + assert setup.name == "Initial setup." + assert setup.success is True + assert isinstance(setup.result, MockSelfTest) + assert setup.result.called_with_args == () + assert setup.result.called_with_kwargs == dict(extra_arg="a value") + assert test.name == "a test result" # The `data` variable contains a dictionary describing the test # suite as a whole. @@ -161,119 +156,114 @@ def _run_self_tests(self, _db): assert r2 == test.to_dict # A JSON version of `data` is stored in the - # ExternalIntegration returned by the external_integration() + # Integration returned by the integration() # method. - [result_setting] = integration.settings - assert HasSelfTests.SELF_TEST_RESULTS_SETTING == result_setting.key - assert data == result_setting.json_value + assert integration.self_test_results == data # Remove the testing integration to show what happens when # HasSelfTests doesn't support the storage of test results. - Tester.integration = None - result_setting.value = "this value will not be changed" + monkeypatch.setattr(MockSelfTest, "_integration", None) # You can specify a different class method to use as the # constructor. Once the object is instantiated, the same basic # code runs. - data, [setup, test] = Tester.run_self_tests( + integration.self_test_results = "this value will not be changed" + data, [setup, test] = MockSelfTest.run_self_tests( mock_db, - Tester.good_alternate_constructor, - another_extra_arg="another value", + lambda **kwargs: MockSelfTest(extra_extra_arg="foo", **kwargs), + extra_arg="a value", ) assert "Initial setup." == setup.name - assert True == setup.success - assert None == setup.result.invoked_with - assert "another value" == setup.result.another_extra_arg + assert setup.success is True + assert setup.result.called_with_args == () + assert setup.result.called_with_kwargs == dict( + extra_extra_arg="foo", extra_arg="a value" + ) assert "a test result" == test.name # Since the HasSelfTests object no longer has an associated - # ExternalIntegration, the test results are not persisted + # Integration, the test results are not persisted # anywhere. - assert "this value will not be changed" == result_setting.value + assert integration.self_test_results == "this value will not be changed" # If there's an exception in the constructor, the result is a # single SelfTestResult describing that failure. Since there is # no instance, _run_self_tests can't be called. - data, [result] = Tester.run_self_tests( + data, [result] = MockSelfTest.run_self_tests( mock_db, - Tester.bad_alternate_constructor, + MagicMock(side_effect=Exception("I don't work!")), ) assert isinstance(result, SelfTestResult) - assert False == result.success - assert "I don't work!" == str(result.exception) + assert result.success is False + assert str(result.exception) == "I don't work!" def test_exception_in_has_self_tests(self): """An exception raised in has_self_tests itself is converted into a test failure. """ - class Tester(HasSelfTests): - def _run_self_tests(self, _db): - yield SelfTestResult("everything's ok so far") - raise Exception("oh no") - yield SelfTestResult("i'll never be called.") + status, [init, failure] = MockSelfTest.run_self_tests(MagicMock()) + assert init.name == "Initial setup." - status, [init, success, failure] = Tester.run_self_tests(object()) - assert "Initial setup." == init.name - assert "everything's ok so far" == success.name - - assert "Uncaught exception in the self-test method itself." == failure.name - assert False == failure.success + assert failure.name == "Uncaught exception in the self-test method itself." + assert failure.success is False # The Exception was turned into an IntegrationException so that # its traceback could be included as debug_message. assert isinstance(failure.exception, IntegrationException) - assert "oh no" == str(failure.exception) + assert str(failure.exception) == "oh no" assert failure.exception.debug_message.startswith("Traceback") def test_run_test_success(self): - o = MockSelfTest() + mock = MockSelfTest() # This self-test method will succeed. def successful_test(arg, kwarg): return arg, kwarg - result = o.run_test("A successful test", successful_test, "arg1", kwarg="arg2") - assert True == result.success - assert "A successful test" == result.name - assert ("arg1", "arg2") == result.result + result = mock.run_test( + "A successful test", successful_test, "arg1", kwarg="arg2" + ) + assert result.success is True + assert result.name == "A successful test" + assert result.result == ("arg1", "arg2") assert (result.end - result.start).total_seconds() < 1 def test_run_test_failure(self): - o = MockSelfTest() + mock = MockSelfTest() # This self-test method will fail. def unsuccessful_test(arg, kwarg): raise IntegrationException(arg, kwarg) - result = o.run_test( + result = mock.run_test( "An unsuccessful test", unsuccessful_test, "arg1", kwarg="arg2" ) - assert False == result.success - assert "An unsuccessful test" == result.name - assert None == result.result - assert "arg1" == str(result.exception) - assert "arg2" == result.exception.debug_message + assert result.success is False + assert result.name == "An unsuccessful test" + assert result.result is None + assert str(result.exception) == "arg1" + assert result.exception.debug_message == "arg2" assert (result.end - result.start).total_seconds() < 1 def test_test_failure(self): - o = MockSelfTest() + mock = MockSelfTest() # You can pass in an Exception... exception = Exception("argh") now = utc_now() - result = o.test_failure("a failure", exception) + result = mock.test_failure("a failure", exception) # ...which will be turned into an IntegrationException. - assert "a failure" == result.name + assert result.name == "a failure" assert isinstance(result.exception, IntegrationException) - assert "argh" == str(result.exception) + assert str(result.exception) == "argh" assert (result.start - now).total_seconds() < 1 # ... or you can pass in arguments to an IntegrationException - result = o.test_failure("another failure", "message", "debug") + result = mock.test_failure("another failure", "message", "debug") assert isinstance(result.exception, IntegrationException) - assert "message" == str(result.exception) - assert "debug" == result.exception.debug_message + assert str(result.exception) == "message" + assert result.exception.debug_message == "debug" # Since no test code actually ran, the end time is the # same as the start time. diff --git a/tests/fixtures/api_admin.py b/tests/fixtures/api_admin.py index 081841cc9..b536219df 100644 --- a/tests/fixtures/api_admin.py +++ b/tests/fixtures/api_admin.py @@ -1,9 +1,11 @@ from contextlib import contextmanager +from unittest.mock import MagicMock import flask import pytest from api.admin.controller import setup_admin_controllers +from api.admin.controller.settings import SettingsController from api.app import initialize_admin from api.circulation_manager import CirculationManager from api.config import Configuration @@ -100,6 +102,10 @@ def __init__(self, controller_fixture: ControllerFixture): # Make the admin a system admin so they can do everything by default. self.admin.add_role(AdminRole.SYSTEM_ADMIN) + mock_manager = MagicMock() + mock_manager._db = self.ctrl.db.session + self.controller = SettingsController(mock_manager) + def do_request(self, url, *args, **kwargs): """Mock HTTP get/post method to replace HTTP.get_with_timeout or post_with_timeout.""" self.requests.append((url, args, kwargs)) diff --git a/tests/fixtures/api_controller.py b/tests/fixtures/api_controller.py index ae4d01ba3..0187413c9 100644 --- a/tests/fixtures/api_controller.py +++ b/tests/fixtures/api_controller.py @@ -37,9 +37,11 @@ IntegrationConfiguration, IntegrationLibraryConfiguration, ) +from core.service.container import Services, wire_container from core.util import base64 from tests.api.mockapi.circulation import MockCirculationManager from tests.fixtures.database import DatabaseTransactionFixture +from tests.mocks.search import ExternalSearchIndexFake class ControllerFixtureSetupOverrides: @@ -97,11 +99,17 @@ def __init__( # were created in the test setup. app.config["PRESERVE_CONTEXT_ON_EXCEPTION"] = False + # Set up the fake search index. + self.search_index = ExternalSearchIndexFake() + self.services_container = Services() + self.services_container.search.index.override(self.search_index) + if setup_cm: # NOTE: Any reference to self._default_library below this # point in this method will cause the tests in # TestScopedSession to hang. self.set_base_url() + app.manager = self.circulation_manager_setup() def set_base_url(self): @@ -110,6 +118,18 @@ def set_base_url(self): ) base_url.value = "http://test-circulation-manager/" + def wire_container(self): + wire_container(self.services_container) + + def unwire_container(self): + self.services_container.unwire() + + @contextmanager + def wired_container(self): + self.wire_container() + yield + self.unwire_container() + def circulation_manager_setup_with_session( self, session: Session, overrides: ControllerFixtureSetupOverrides | None = None ) -> CirculationManager: @@ -159,7 +179,9 @@ def circulation_manager_setup_with_session( self.default_patron = self.default_patrons[self.library] self.authdata = AuthdataUtility.from_config(self.library) - self.manager = MockCirculationManager(session) + + # Create mock CM instance + self.manager = MockCirculationManager(session, self.services_container) # Set CirculationAPI and top-level lane for the default # library, for convenience in tests. @@ -339,33 +361,6 @@ def add_works(self, works: list[WorkSpec]): ) self.manager.external_search.mock_query_works_multi(self.works) - def assert_bad_search_index_gives_problem_detail(self, test_function): - """Helper method to test that a controller method serves a problem - detail document when the search index isn't set up. - - Mocking a broken search index is a lot of work; thus the helper method. - """ - old_setup = self.manager.setup_external_search - old_value = self.manager._external_search - - try: - self.manager._external_search = None - self.manager.setup_external_search = lambda: None - with self.request_context_with_library("/"): - response = test_function() - assert 502 == response.status_code - assert ( - "http://librarysimplified.org/terms/problem/remote-integration-failed" - == response.uri - ) - assert ( - "The search index for this site is not properly configured." - == response.detail - ) - finally: - self.manager.setup_external_search = old_setup - self.manager._external_search = old_value - @pytest.fixture(scope="function") def circulation_fixture(db: DatabaseTransactionFixture): diff --git a/tests/fixtures/api_routes.py b/tests/fixtures/api_routes.py index e9823dbad..3ecdb0b69 100644 --- a/tests/fixtures/api_routes.py +++ b/tests/fixtures/api_routes.py @@ -1,6 +1,7 @@ import logging from collections.abc import Generator from typing import Any +from unittest.mock import MagicMock import flask import pytest @@ -128,7 +129,7 @@ def __init__( self.controller_fixture = controller_fixture self.setup_circulation_manager = False if not RouteTestFixture.REAL_CIRCULATION_MANAGER: - manager = MockCirculationManager(self.db.session) + manager = MockCirculationManager(self.db.session, MagicMock()) RouteTestFixture.REAL_CIRCULATION_MANAGER = manager app = MockApp() diff --git a/tests/fixtures/container.py b/tests/fixtures/container.py deleted file mode 100644 index 8d3077493..000000000 --- a/tests/fixtures/container.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from core.service.container import container_instance - - -@pytest.fixture(autouse=True) -def services_container_instance(): - # This creates and wires the container - return container_instance() diff --git a/tests/fixtures/flask.py b/tests/fixtures/flask.py index 670b7d908..3ef24f849 100644 --- a/tests/fixtures/flask.py +++ b/tests/fixtures/flask.py @@ -1,28 +1,67 @@ +from __future__ import annotations + from collections.abc import Generator +from contextlib import contextmanager +from typing import Any +import flask import pytest from flask.ctx import RequestContext from flask_babel import Babel +from werkzeug.datastructures import ImmutableMultiDict from api.util.flask import PalaceFlask +from core.model import Admin, AdminRole, Library, get_one_or_create +from tests.fixtures.database import DatabaseTransactionFixture -@pytest.fixture -def mock_app() -> PalaceFlask: - app = PalaceFlask(__name__) - Babel(app) - return app +class FlaskAppFixture: + def __init__(self, db: DatabaseTransactionFixture) -> None: + self.app = PalaceFlask(__name__) + self.db = db + Babel(self.app) + def admin_user( + self, + email: str = "admin@admin.org", + role: str = AdminRole.SYSTEM_ADMIN, + library: Library | None = None, + ) -> Admin: + admin, _ = get_one_or_create(self.db.session, Admin, email=email) + admin.add_role(role, library) + return admin -@pytest.fixture -def get_request_context(mock_app: PalaceFlask) -> Generator[RequestContext, None, None]: - with mock_app.test_request_context("/") as mock_request_context: - yield mock_request_context + @contextmanager + def test_request_context( + self, + *args: Any, + admin: Admin | None = None, + library: Library | None = None, + **kwargs: Any, + ) -> Generator[RequestContext, None, None]: + with self.app.test_request_context(*args, **kwargs) as c: + self.db.session.begin_nested() + flask.request.library = library # type: ignore[attr-defined] + flask.request.admin = admin # type: ignore[attr-defined] + flask.request.form = ImmutableMultiDict() + flask.request.files = ImmutableMultiDict() + yield c + + # Flush any changes that may have occurred during the request, then + # expire all objects to ensure that the next request will see the + # changes. + self.db.session.commit() + self.db.session.expire_all() + + @contextmanager + def test_request_context_system_admin( + self, *args: Any, **kwargs: Any + ) -> Generator[RequestContext, None, None]: + admin = self.admin_user() + with self.test_request_context(*args, **kwargs, admin=admin) as c: + yield c @pytest.fixture -def post_request_context( - mock_app: PalaceFlask, -) -> Generator[RequestContext, None, None]: - with mock_app.test_request_context("/", method="POST") as mock_request_context: - yield mock_request_context +def flask_app_fixture(db: DatabaseTransactionFixture) -> FlaskAppFixture: + return FlaskAppFixture(db) diff --git a/tests/fixtures/search.py b/tests/fixtures/search.py index bb5429094..63f7c4c75 100644 --- a/tests/fixtures/search.py +++ b/tests/fixtures/search.py @@ -1,74 +1,82 @@ -import logging -import os -from collections.abc import Iterable +from __future__ import annotations + +from collections.abc import Generator import pytest from opensearchpy import OpenSearch - -from core.external_search import ExternalSearchIndex, SearchIndexCoverageProvider -from core.model import ExternalIntegration, Work +from pydantic import AnyHttpUrl + +from core.external_search import ExternalSearchIndex +from core.model import Work +from core.search.coverage_provider import SearchIndexCoverageProvider +from core.search.service import SearchServiceOpensearch1 +from core.service.configuration import ServiceConfiguration +from core.service.container import Services, wire_container +from core.service.search.container import Search +from core.util.log import LoggerMixin from tests.fixtures.database import DatabaseTransactionFixture +from tests.fixtures.services import ServicesFixture from tests.mocks.search import SearchServiceFake -class ExternalSearchFixture: +class SearchTestConfiguration(ServiceConfiguration): + url: AnyHttpUrl + index_prefix: str = "test_index" + timeout: int = 20 + maxsize: int = 25 + + class Config: + env_prefix = "PALACE_TEST_SEARCH_" + + +class ExternalSearchFixture(LoggerMixin): """ - These tests require opensearch to be running locally. If it's not, or there's - an error creating the index, the tests will pass without doing anything. + These tests require opensearch to be running locally. Tests for opensearch are useful for ensuring that we haven't accidentally broken a type of search by changing analyzers or queries, but search needs to be tested manually to ensure that it works well overall, with a realistic index. """ - integration: ExternalIntegration - db: DatabaseTransactionFixture - search: OpenSearch - _indexes_created: list[str] + def __init__(self, db: DatabaseTransactionFixture, services: Services): + self.search_config = SearchTestConfiguration() + self.services_container = services - def __init__(self): + # Set up our testing search instance in the services container + self.search_container = Search() + self.search_container.config.from_dict(self.search_config.dict()) + self.services_container.search.override(self.search_container) + + self._indexes_created: list[str] = [] + self.db = db + self.client: OpenSearch = services.search.client() + self.service: SearchServiceOpensearch1 = services.search.service() + self.index: ExternalSearchIndex = services.search.index() self._indexes_created = [] - self._logger = logging.getLogger(ExternalSearchFixture.__name__) - - @classmethod - def create(cls, db: DatabaseTransactionFixture) -> "ExternalSearchFixture": - fixture = ExternalSearchFixture() - fixture.db = db - fixture.integration = db.external_integration( - ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - url=fixture.url, - settings={ - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY: "test_index", - ExternalSearchIndex.TEST_SEARCH_TERM_KEY: "test_search_term", - }, - ) - fixture.search = OpenSearch(fixture.url, use_ssl=False, timeout=20, maxsize=25) - return fixture - @property - def url(self) -> str: - env = os.environ.get("SIMPLIFIED_TEST_OPENSEARCH") - if env is None: - raise OSError("SIMPLIFIED_TEST_OPENSEARCH is not defined.") - return env + # Make sure the services container is wired up with the newly created search container + wire_container(self.services_container) def record_index(self, name: str): - self._logger.info(f"Recording index {name} for deletion") + self.log.info(f"Recording index {name} for deletion") self._indexes_created.append(name) def close(self): for index in self._indexes_created: try: - self._logger.info(f"Deleting index {index}") - self.search.indices.delete(index) + self.log.info(f"Deleting index {index}") + self.client.indices.delete(index) except Exception as e: - self._logger.info(f"Failed to delete index {index}: {e}") + self.log.info(f"Failed to delete index {index}: {e}") # Force test index deletion - self.search.indices.delete("test_index*") - self._logger.info("Waiting for operations to complete.") - self.search.indices.refresh() + self.client.indices.delete("test_index*") + self.log.info("Waiting for operations to complete.") + self.client.indices.refresh() + + # Unwire the services container + self.services_container.unwire() + self.services_container.search.reset_override() return None def default_work(self, *args, **kwargs): @@ -83,54 +91,38 @@ def default_work(self, *args, **kwargs): return work def init_indices(self): - client = ExternalSearchIndex(self.db.session) - client.initialize_indices() + self.index.initialize_indices() @pytest.fixture(scope="function") def external_search_fixture( - db: DatabaseTransactionFixture, -) -> Iterable[ExternalSearchFixture]: + db: DatabaseTransactionFixture, services_fixture: ServicesFixture +) -> Generator[ExternalSearchFixture, None, None]: """Ask for an external search system.""" """Note: You probably want EndToEndSearchFixture instead.""" - data = ExternalSearchFixture.create(db) - yield data - data.close() + fixture = ExternalSearchFixture(db, services_fixture.services) + yield fixture + fixture.close() class EndToEndSearchFixture: """An external search system fixture that can be populated with data for end-to-end tests.""" """Tests are expected to call the `populate()` method to populate the fixture with test-specific data.""" - external_search: ExternalSearchFixture - external_search_index: ExternalSearchIndex - db: DatabaseTransactionFixture - - def __init__(self): - self._logger = logging.getLogger(EndToEndSearchFixture.__name__) - @classmethod - def create(cls, transaction: DatabaseTransactionFixture) -> "EndToEndSearchFixture": - data = EndToEndSearchFixture() - data.db = transaction - data.external_search = ExternalSearchFixture.create(transaction) - data.external_search_index = ExternalSearchIndex(transaction.session) - return data + def __init__(self, search_fixture: ExternalSearchFixture): + self.db = search_fixture.db + self.external_search = search_fixture + self.external_search_index = search_fixture.index def populate_search_index(self): """Populate the search index with a set of works. The given callback is passed this fixture instance.""" - - # Create some works. - if not self.external_search.search: - # No search index is configured -- nothing to do. - return - # Add all the works created in the setup to the search index. SearchIndexCoverageProvider( self.external_search.db.session, search_index_client=self.external_search_index, ).run() - self.external_search.search.indices.refresh() + self.external_search.client.indices.refresh() @staticmethod def assert_works(description, expect, actual, should_be_ordered=True): @@ -249,48 +241,43 @@ def close(self): for index in self.external_search_index.search_service().indexes_created(): self.external_search.record_index(index) - self.external_search.close() - @pytest.fixture(scope="function") def end_to_end_search_fixture( - db: DatabaseTransactionFixture, -) -> Iterable[EndToEndSearchFixture]: + external_search_fixture: ExternalSearchFixture, +) -> Generator[EndToEndSearchFixture, None, None]: """Ask for an external search system that can be populated with data for end-to-end tests.""" - data = EndToEndSearchFixture.create(db) - try: - yield data - except Exception: - raise - finally: - data.close() + fixture = EndToEndSearchFixture(external_search_fixture) + yield fixture + fixture.close() class ExternalSearchFixtureFake: - integration: ExternalIntegration - db: DatabaseTransactionFixture - search: SearchServiceFake - external_search: ExternalSearchIndex + def __init__(self, db: DatabaseTransactionFixture, services: Services): + self.db = db + self.services = services + self.search_container = Search() + self.services.search.override(self.search_container) + + self.service = SearchServiceFake() + self.search_container.service.override(self.service) + self.external_search: ExternalSearchIndex = self.services.search.index() + + wire_container(self.services) + + def close(self): + self.services.unwire() + self.services.search.reset_override() @pytest.fixture(scope="function") def external_search_fake_fixture( - db: DatabaseTransactionFixture, -) -> ExternalSearchFixtureFake: + db: DatabaseTransactionFixture, services_fixture: ServicesFixture +) -> Generator[ExternalSearchFixtureFake, None, None]: """Ask for an external search system that can be populated with data for end-to-end tests.""" - data = ExternalSearchFixtureFake() - data.db = db - data.integration = db.external_integration( - ExternalIntegration.OPENSEARCH, - goal=ExternalIntegration.SEARCH_GOAL, - url="http://does-not-exist.com/", - settings={ - ExternalSearchIndex.WORKS_INDEX_PREFIX_KEY: "test_index", - ExternalSearchIndex.TEST_SEARCH_TERM_KEY: "a search term", - }, - ) - data.search = SearchServiceFake() - data.external_search = ExternalSearchIndex( - _db=db.session, custom_client_service=data.search + fixture = ExternalSearchFixtureFake( + db=db, + services=services_fixture.services, ) - return data + yield fixture + fixture.close() diff --git a/tests/fixtures/services.py b/tests/fixtures/services.py index edbeccb52..52600241f 100644 --- a/tests/fixtures/services.py +++ b/tests/fixtures/services.py @@ -1,42 +1,155 @@ -from unittest.mock import MagicMock +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from unittest.mock import MagicMock, create_autospec +import boto3 import pytest -from core.service.container import Services +from core.analytics import Analytics +from core.external_search import ExternalSearchIndex +from core.search.revision_directory import SearchRevisionDirectory +from core.search.service import SearchServiceOpensearch1 +from core.service.analytics.container import AnalyticsContainer +from core.service.container import Services, wire_container +from core.service.logging.container import Logging +from core.service.logging.log import setup_logging +from core.service.search.container import Search from core.service.storage.container import Storage from core.service.storage.s3 import S3Service -class MockStorageFixture: - def __init__(self): - self.storage = Storage() - self.analytics = MagicMock(spec=S3Service) - self.storage.analytics.override(self.analytics) - self.public = MagicMock(spec=S3Service) - self.storage.public.override(self.public) - self.s3_client = MagicMock() - self.storage.s3_client.override(self.s3_client) +@contextmanager +def mock_services_container( + services_container: Services, +) -> Generator[None, None, None]: + from core.service import container + + container._container_instance = services_container + yield + container._container_instance = None + + +@dataclass +class ServicesLoggingFixture: + logging_container: Logging + logging_mock: MagicMock @pytest.fixture -def mock_storage_fixture() -> MockStorageFixture: - return MockStorageFixture() +def services_logging_fixture() -> ServicesLoggingFixture: + logging_container = Logging() + logging_mock = create_autospec(setup_logging) + logging_container.logging.override(logging_mock) + return ServicesLoggingFixture(logging_container, logging_mock) -class MockServicesFixture: +@dataclass +class ServicesStorageFixture: + storage_container: Storage + s3_client_mock: MagicMock + analytics_mock: MagicMock + public_mock: MagicMock + + +@pytest.fixture +def services_storage_fixture() -> ServicesStorageFixture: + storage_container = Storage() + s3_client_mock = create_autospec(boto3.client) + analytics_mock = create_autospec(S3Service.factory) + public_mock = create_autospec(S3Service.factory) + storage_container.s3_client.override(s3_client_mock) + storage_container.analytics.override(analytics_mock) + storage_container.public.override(public_mock) + return ServicesStorageFixture( + storage_container, s3_client_mock, analytics_mock, public_mock + ) + + +@dataclass +class ServicesSearchFixture: + search_container: Search + client_mock: MagicMock + service_mock: MagicMock + revision_directory_mock: MagicMock + index_mock: MagicMock + + +@pytest.fixture +def services_search_fixture() -> ServicesSearchFixture: + search_container = Search() + client_mock = create_autospec(boto3.client) + service_mock = create_autospec(SearchServiceOpensearch1) + revision_directory_mock = create_autospec(SearchRevisionDirectory.create) + index_mock = create_autospec(ExternalSearchIndex) + search_container.client.override(client_mock) + search_container.service.override(service_mock) + search_container.revision_directory.override(revision_directory_mock) + search_container.index.override(index_mock) + return ServicesSearchFixture( + search_container, client_mock, service_mock, revision_directory_mock, index_mock + ) + + +@dataclass +class ServicesAnalyticsFixture: + analytics_container: AnalyticsContainer + analytics_mock: MagicMock + + +@pytest.fixture +def services_analytics_fixture() -> ServicesAnalyticsFixture: + analytics_container = AnalyticsContainer() + analytics_mock = create_autospec(Analytics) + analytics_container.analytics.override(analytics_mock) + return ServicesAnalyticsFixture(analytics_container, analytics_mock) + + +class ServicesFixture: """ - Provide a services container with all the services mocked out - by MagicMock objects. + Provide a real services container, with all services mocked out. """ - def __init__(self, storage: MockStorageFixture): + def __init__( + self, + logging: ServicesLoggingFixture, + storage: ServicesStorageFixture, + search: ServicesSearchFixture, + analytics: ServicesAnalyticsFixture, + ) -> None: + self.logging_fixture = logging + self.storage_fixture = storage + self.search_fixture = search + self.analytics_fixture = analytics + self.services = Services() - self.services.storage.override(storage.storage) - self.storage = storage + self.services.logging.override(logging.logging_container) + self.services.storage.override(storage.storage_container) + self.services.search.override(search.search_container) + self.services.analytics.override(analytics.analytics_container) + + +@pytest.fixture(autouse=True) +def services_fixture( + services_logging_fixture: ServicesLoggingFixture, + services_storage_fixture: ServicesStorageFixture, + services_search_fixture: ServicesSearchFixture, + services_analytics_fixture: ServicesAnalyticsFixture, +) -> Generator[ServicesFixture, None, None]: + fixture = ServicesFixture( + logging=services_logging_fixture, + storage=services_storage_fixture, + search=services_search_fixture, + analytics=services_analytics_fixture, + ) + with mock_services_container(fixture.services): + yield fixture @pytest.fixture -def mock_services_fixture( - mock_storage_fixture: MockStorageFixture, -) -> MockServicesFixture: - return MockServicesFixture(mock_storage_fixture) +def services_fixture_wired( + services_fixture: ServicesFixture, +) -> Generator[ServicesFixture, None, None]: + wire_container(services_fixture.services) + yield services_fixture + services_fixture.services.unwire() diff --git a/tests/migration/conftest.py b/tests/migration/conftest.py index c537d93b1..85957ecd4 100644 --- a/tests/migration/conftest.py +++ b/tests/migration/conftest.py @@ -13,6 +13,7 @@ from core.model import json_serializer from tests.fixtures.database import ApplicationFixture, DatabaseFixture +from tests.fixtures.services import ServicesFixture if TYPE_CHECKING: from pytest_alembic import MigrationContext @@ -21,8 +22,15 @@ import alembic.config +pytest_plugins = [ + "tests.fixtures.services", +] + + @pytest.fixture(scope="function") -def application() -> Generator[ApplicationFixture, None, None]: +def application( + services_fixture: ServicesFixture, +) -> Generator[ApplicationFixture, None, None]: app = ApplicationFixture.create() yield app app.close() diff --git a/tests/migration/test_instance_init_script.py b/tests/migration/test_instance_init_script.py index 62a8f8b4a..8c413c548 100644 --- a/tests/migration/test_instance_init_script.py +++ b/tests/migration/test_instance_init_script.py @@ -2,7 +2,7 @@ import sys from io import StringIO from multiprocessing import Process -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from pytest_alembic import MigrationContext from sqlalchemy import inspect @@ -11,15 +11,19 @@ from core.model import SessionManager from scripts import InstanceInitializationScript from tests.fixtures.database import ApplicationFixture +from tests.fixtures.services import mock_services_container def _run_script() -> None: try: - # Run the script, capturing the log output - script = InstanceInitializationScript() + # Capturing the log output stream = StringIO() logging.basicConfig(stream=stream, level=logging.INFO, force=True) - script.run() + + mock_services = MagicMock() + with mock_services_container(mock_services): + script = InstanceInitializationScript() + script.run() # Set our exit code to the number of upgrades we ran sys.exit(stream.getvalue().count("Running upgrade")) diff --git a/tests/mocks/search.py b/tests/mocks/search.py index eebf8f399..ffdb9e487 100644 --- a/tests/mocks/search.py +++ b/tests/mocks/search.py @@ -9,7 +9,6 @@ from opensearchpy import OpenSearchException from core.external_search import ExternalSearchIndex -from core.model import Work from core.model.work import Work from core.search.revision import SearchSchemaRevision from core.search.revision_directory import SearchRevisionDirectory @@ -50,6 +49,10 @@ def __init__(self): self._indexes_created = [] self._document_submission_attempts = [] + @property + def base_revision_name(self) -> str: + return self.base_name + @property def document_submission_attempts(self) -> list[dict]: return self._document_submission_attempts @@ -227,14 +230,14 @@ class ExternalSearchIndexFake(ExternalSearchIndex): def __init__( self, - _db, - url: str | None = None, - test_search_term: str | None = None, revision_directory: SearchRevisionDirectory | None = None, version: int | None = None, ): + revision_directory = revision_directory or SearchRevisionDirectory.create() super().__init__( - _db, url, test_search_term, revision_directory, version, SearchServiceFake() + service=SearchServiceFake(), + revision_directory=revision_directory, + version=version, ) self._mock_multi_works: list[dict] = [] diff --git a/tox.ini b/tox.ini index b95fd8cbe..f77003a43 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ passenv = setenv = {api,core}: COVERAGE_FILE = .coverage.{envname} docker: SIMPLIFIED_TEST_DATABASE=postgresql://simplified_test:test@localhost:9005/simplified_circulation_test - docker: SIMPLIFIED_TEST_OPENSEARCH=http://localhost:9007 + docker: PALACE_TEST_SEARCH_URL=http://localhost:9007 core-docker: PALACE_TEST_MINIO_ENDPOINT_URL=http://localhost:9004 core-docker: PALACE_TEST_MINIO_USER=palace core-docker: PALACE_TEST_MINIO_PASSWORD=12345678901234567890