From 05d8389b079cc0bc5425dd8f16d65da5d97512ed Mon Sep 17 00:00:00 2001 From: David Eckhard Date: Tue, 15 Feb 2022 17:06:39 +0100 Subject: [PATCH] feature: extend OAI API with search and format test: add tests for OAI API --- .../oaiserver/resources/config.py | 21 +- .../oaiserver/resources/resources.py | 6 +- .../oaiserver/services/config.py | 94 ++++---- .../oaiserver/services/links.py | 5 - .../oaiserver/services/permissions.py | 7 +- .../oaiserver/services/results.py | 35 ++- .../oaiserver/services/services.py | 106 ++++++--- invenio_rdm_records/oaiserver/services/uow.py | 2 +- tests/conftest.py | 51 +++++ tests/resources/test_resources_oai.py | 212 ++++++++++++++++++ 10 files changed, 450 insertions(+), 89 deletions(-) create mode 100644 tests/resources/test_resources_oai.py diff --git a/invenio_rdm_records/oaiserver/resources/config.py b/invenio_rdm_records/oaiserver/resources/config.py index 403aa867d6..eafc8f6d98 100644 --- a/invenio_rdm_records/oaiserver/resources/config.py +++ b/invenio_rdm_records/oaiserver/resources/config.py @@ -19,16 +19,32 @@ SearchRequestArgsSchema, ) -from ..services.errors import OAIPMHError +from invenio_rdm_records.oaiserver.services.errors import ( + OAIPMHError, + OAIPMHSetDoesNotExistError, + OAIPMHSetIDDoesNotExistError, +) oaipmh_error_handlers = { **ErrorHandlersMixin.error_handlers, - OAIPMHError: create_error_handler( + OAIPMHSetDoesNotExistError: create_error_handler( + lambda e: HTTPJSONException( + code=404, + description=e.description, + ) + ), + OAIPMHSetIDDoesNotExistError: create_error_handler( lambda e: HTTPJSONException( code=404, description=e.description, ) ), + OAIPMHError: create_error_handler( + lambda e: HTTPJSONException( + code=400, + description=e.description, + ) + ), } @@ -36,6 +52,7 @@ class OAIPMHServerSearchRequestArgsSchema(SearchRequestArgsSchema): """OAI-PMH request parameters.""" managed = ma.fields.Boolean() + sort_direction = ma.fields.Str() class OAIPMHServerResourceConfig(ResourceConfig): diff --git a/invenio_rdm_records/oaiserver/resources/resources.py b/invenio_rdm_records/oaiserver/resources/resources.py index 4e2022ebd0..9aad84740f 100644 --- a/invenio_rdm_records/oaiserver/resources/resources.py +++ b/invenio_rdm_records/oaiserver/resources/resources.py @@ -41,7 +41,9 @@ def create_url_rules(self): route("POST", routes["set-prefix"], self.create), route("GET", routes["set-prefix"] + routes["item"], self.read), route("PUT", routes["set-prefix"] + routes["item"], self.update), - route("DELETE", routes["set-prefix"] + routes["item"], self.delete), + route( + "DELETE", routes["set-prefix"] + routes["item"], self.delete + ), route( "GET", routes["format-prefix"] + routes["list"], @@ -117,4 +119,4 @@ def read_formats(self): hits = self.service.read_all_formats( identity=identity, ) - return hits, 200 + return hits.to_dict(), 200 diff --git a/invenio_rdm_records/oaiserver/services/config.py b/invenio_rdm_records/oaiserver/services/config.py index bac854bbdd..21f6316802 100644 --- a/invenio_rdm_records/oaiserver/services/config.py +++ b/invenio_rdm_records/oaiserver/services/config.py @@ -2,33 +2,27 @@ # # Copyright (C) 2022 Graz University of Technology. # -# Invenio-Records-Resources is free software; you can redistribute it and/or -# modify it under the terms of the MIT License; see LICENSE file for more -# details. +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. """OAI-PMH service API configuration.""" from flask_babelex import gettext as _ -from invenio_indexer.api import RecordIndexer from invenio_oaiserver.models import OAISet from invenio_records_resources.services import ServiceConfig from invenio_records_resources.services.base import Link from invenio_records_resources.services.records.links import pagination_links -from invenio_records_resources.services.records.params import ( - FacetsParam, - PaginationParam, - QueryParser, - QueryStrParam, - SortParam, -) -from invenio_search import RecordsSearchV2 from marshmallow import Schema, fields, validate +from marshmallow_utils.fields import SanitizedUnicode +from sqlalchemy import asc, desc from invenio_rdm_records.oaiserver.services.links import OAIPMHSetLink from invenio_rdm_records.oaiserver.services.permissions import ( OAIPMHServerPermissionPolicy, ) from invenio_rdm_records.oaiserver.services.results import ( + OAIMetadataFormatItem, + OAIMetadataFormatList, OAISetItem, OAISetList, ) @@ -37,45 +31,58 @@ class SearchOptions: """Search options.""" - search_cls = RecordsSearchV2 - query_parser_cls = QueryParser - suggest_parser_cls = None - sort_default = 'bestmatch' - sort_default_no_query = 'newest' + sort_default = 'created' + sort_direction_default = 'asc' + + sort_direction_options = { + "asc": dict( + title=_('Ascending'), + fn=asc, + ), + "desc": dict( + title=_('Descending'), + fn=desc, + ), + } + sort_options = { - "bestmatch": dict( - title=_('Best match'), - fields=['_score'], # ES defaults to desc on `_score` field + "name": dict( + title=_('Name'), + fields=['name'], ), - "newest": dict( - title=_('Newest'), - fields=['-created'], + "spec": dict( + title=_('Spec'), + fields=['spec'], ), - "oldest": dict( - title=_('Oldest'), + "created": dict( + title=_('Created'), fields=['created'], ), + "updated": dict( + title=_('Updated'), + fields=['updated'], + ), } - facets = {} pagination_options = { "default_results_per_page": 25, - "default_max_results": 10000, } - params_interpreters_cls = [ - QueryStrParam, - PaginationParam, - SortParam, - FacetsParam, - ] + + +class OAIPMHMetadataFormat(Schema): + """Marshmallow schema for OAI-PMH metadata format.""" + + id = fields.Str(read_only=True) + schema = fields.URL(read_only=True) + namespace = fields.URL(read_only=True) class OAIPMHSetSchema(Schema): """Marshmallow schema for OAI-PMH set.""" - description = fields.Str(required=True) - name = fields.Str(required=True, validate=validate.Length(min=1)) - search_pattern = fields.Str(required=True, validate=validate.Length(min=1)) - spec = fields.Str(required=True, validate=validate.Length(min=1)) + description = SanitizedUnicode(missing=None, default=None) + name = SanitizedUnicode(required=True, validate=validate.Length(min=1)) + search_pattern = SanitizedUnicode(required=True) + spec = SanitizedUnicode(required=True, validate=validate.Length(min=1)) created = fields.DateTime(read_only=True) updated = fields.DateTime(read_only=True) id = fields.Int(read_only=True) @@ -84,9 +91,9 @@ class OAIPMHSetSchema(Schema): class OAIPMHSetUpdateSchema(Schema): """Marshmallow schema for OAI-PMH set update request.""" - description = fields.Str(validate=validate.Length(min=1)) - name = fields.Str(validate=validate.Length(min=1)) - search_pattern = fields.Str(validate=validate.Length(min=1)) + description = SanitizedUnicode(missing=None) + name = fields.Str(required=True, validate=validate.Length(min=1)) + search_pattern = fields.Str(required=True) class OAIPMHServerServiceConfig(ServiceConfig): @@ -97,10 +104,11 @@ class OAIPMHServerServiceConfig(ServiceConfig): result_item_cls = OAISetItem result_list_cls = OAISetList + metadata_format_result_item_cls = OAIMetadataFormatItem + metadata_format_result_list_cls = OAIMetadataFormatList + # Record specific configuration record_cls = OAISet - indexer_cls = None - index_dumper = None # Search configuration search = SearchOptions @@ -109,6 +117,8 @@ class OAIPMHServerServiceConfig(ServiceConfig): schema = OAIPMHSetSchema update_schema = OAIPMHSetUpdateSchema + metadata_format_schema = OAIPMHMetadataFormat + links_item = { "self": OAIPMHSetLink("{+api}/oaipmh/sets/{id}"), "oai-listrecords": OAIPMHSetLink( diff --git a/invenio_rdm_records/oaiserver/services/links.py b/invenio_rdm_records/oaiserver/services/links.py index 03ba30daee..54fa9df22f 100644 --- a/invenio_rdm_records/oaiserver/services/links.py +++ b/invenio_rdm_records/oaiserver/services/links.py @@ -22,8 +22,3 @@ def vars(set, vars): "spec": set.spec, } ) - - -class OAIPMHLink(Link): - def __init__(self, verb, metadataFormat=None, params=None): - super().__init__() diff --git a/invenio_rdm_records/oaiserver/services/permissions.py b/invenio_rdm_records/oaiserver/services/permissions.py index 9a89da80cb..9327a166e2 100644 --- a/invenio_rdm_records/oaiserver/services/permissions.py +++ b/invenio_rdm_records/oaiserver/services/permissions.py @@ -8,7 +8,11 @@ """Permissions for OAI-PMH service.""" from invenio_records_permissions import BasePermissionPolicy -from invenio_records_permissions.generators import AnyUser, Admin, SystemProcess +from invenio_records_permissions.generators import ( + Admin, + AnyUser, + SystemProcess, +) class OAIPMHServerPermissionPolicy(BasePermissionPolicy): @@ -18,5 +22,4 @@ class OAIPMHServerPermissionPolicy(BasePermissionPolicy): can_create = [Admin(), SystemProcess()] can_delete = [Admin(), SystemProcess()] can_update = [Admin(), SystemProcess()] - can_search = [AnyUser(), SystemProcess()] can_read_format = [AnyUser(), SystemProcess()] diff --git a/invenio_rdm_records/oaiserver/services/results.py b/invenio_rdm_records/oaiserver/services/results.py index e0742bd3b5..9d107a4e90 100644 --- a/invenio_rdm_records/oaiserver/services/results.py +++ b/invenio_rdm_records/oaiserver/services/results.py @@ -14,12 +14,12 @@ ) -class OAISetItem(ServiceItemResult): - """Single OAI-PMH set result item.""" +class BaseServiceItemResult(ServiceItemResult): + """Single result item.""" - def __init__(self, service, identity, set, links_tpl, schema=None): + def __init__(self, service, identity, item, links_tpl, schema=None): self._identity = identity - self._set = set + self._item = item self._schema = schema or service.schema self._links_tpl = links_tpl self._data = None @@ -27,7 +27,7 @@ def __init__(self, service, identity, set, links_tpl, schema=None): @property def links(self): """Get links for this result item.""" - return self._links_tpl.expand(self._set) + return self._links_tpl.expand(self._item) @property def data(self): @@ -36,7 +36,7 @@ def data(self): return self._data self._data = self._schema.dump( - self._set, + self._item, context=dict( identity=self._identity, ), @@ -52,8 +52,8 @@ def to_dict(self): return res -class OAISetList(ServiceListResult): - """List of records result.""" +class BaseServiceListResult(ServiceListResult): + """List of result items.""" def __init__( self, @@ -69,7 +69,7 @@ def __init__( :params service: a service instance :params identity: an identity that performed the service request - :params results: the search results + :params results: the db search results :params params: dictionary of the query parameters """ self._identity = identity @@ -122,6 +122,7 @@ def to_dict(self): """Return result as a dictionary.""" # TODO: This part should imitate the result item above. I.e. add a # "data" property which uses a ServiceSchema to dump the entire object. + res = { "hits": { "hits": list(self.hits), @@ -134,3 +135,19 @@ def to_dict(self): res['links'] = self._links_tpl.expand(self.pagination) return res + + +class OAISetItem(BaseServiceItemResult): + """Single OAI-PMH set result item.""" + + +class OAISetList(BaseServiceListResult): + """List of OAI-PMH set result items.""" + + +class OAIMetadataFormatItem(BaseServiceItemResult): + """Single OAI-PMH metadata format result item.""" + + +class OAIMetadataFormatList(BaseServiceListResult): + """List of OAI-PMH metadata format result items.""" diff --git a/invenio_rdm_records/oaiserver/services/services.py b/invenio_rdm_records/oaiserver/services/services.py index 656170c0b1..b243757a42 100644 --- a/invenio_rdm_records/oaiserver/services/services.py +++ b/invenio_rdm_records/oaiserver/services/services.py @@ -9,6 +9,7 @@ from flask import current_app from flask_babelex import lazy_gettext as _ +from flask_sqlalchemy import Pagination from invenio_oaiserver.models import OAISet from invenio_records_resources.services import Service from invenio_records_resources.services.base import LinksTemplate @@ -16,17 +17,16 @@ ServiceSchemaWrapper, ) from invenio_records_resources.services.uow import unit_of_work -from invenio_requests import current_registry, current_requests_service from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.sql import text -from invenio_rdm_records.oaiserver.services.config import OAIPMHSetUpdateSchema from invenio_rdm_records.oaiserver.services.errors import ( OAIPMHSetDoesNotExistError, OAIPMHSetIDDoesNotExistError, OAIPMHSetSpecAlreadyExistsError, ) -from .uow import OAISetCommitOp, OAISetDeleteOp +from invenio_rdm_records.oaiserver.services.uow import OAISetCommitOp, OAISetDeleteOp class OAIPMHServerService(Service): @@ -85,7 +85,7 @@ def create(self, identity, data, uow=None): return self.result_item( service=self, identity=identity, - set=new_set, + item=new_set, links_tpl=self.links_item_tpl, ) @@ -97,35 +97,30 @@ def read(self, identity, id_): return self.result_item( service=self, identity=identity, - set=oai_set, + item=oai_set, links_tpl=self.links_item_tpl, ) def search(self, identity, params): - self.require_permission(identity, 'search') - page = params.get("page", 1) - size = params.get( - "size", - self.config.search.pagination_options.get( - "default_results_per_page" - ), - ) + self.require_permission(identity, 'read') - params.update( - { - "page": page, - "size": size, - } - ) + search_params = self._get_search_params(params) - oai_sets = OAISet.query.paginate( - page=page, per_page=size, error_out=False + oai_sets = OAISet.query.order_by( + search_params["sort_direction"]( + text(",".join(search_params["sort"])) + ) + ).paginate( + page=search_params["page"], + per_page=search_params["size"], + error_out=False, ) + return self.result_list( self, identity, oai_sets, - params, + params=search_params, links_tpl=LinksTemplate( self.config.links_search, context={"args": params} ), @@ -151,7 +146,7 @@ def update(self, identity, id_, data, uow=None): return self.result_item( service=self, identity=identity, - set=oai_set, + item=oai_set, links_tpl=self.links_item_tpl, ) @@ -167,6 +162,65 @@ def delete(self, identity, id_, uow=None): def read_all_formats(self, identity): """Read available metadata formats.""" self.require_permission(identity, 'read_format') - # TODO: create ResultItem and schema for formats - formats = list(current_app.config.get('OAISERVER_METADATA_FORMATS').keys()) - return formats + formats = [ + { + "id": k, + "schema": v.get("schema", None), + "namespace": v.get("namespace", None), + } + for k, v in current_app.config.get( + 'OAISERVER_METADATA_FORMATS' + ).items() + ] + + results = Pagination( + query=None, + page=1, + per_page=None, + total=len(formats), + items=formats, + ) + + return self.config.metadata_format_result_list_cls( + self, + identity, + results, + schema=ServiceSchemaWrapper( + self, schema=self.config.metadata_format_schema + ), + ) + + def _get_search_params(self, params): + page = params.get("page", 1) + size = params.get( + "size", + self.config.search.pagination_options.get( + "default_results_per_page" + ), + ) + + _search_cls = self.config.search + + _sort_name = ( + params.get("sort") + if params.get("sort") in _search_cls.sort_options + else _search_cls.sort_default + ) + _sort_direction_name = ( + params.get("sort_direction") + if params.get("sort_direction") + in _search_cls.sort_direction_options + else _search_cls.sort_direction_default + ) + + sort = _search_cls.sort_options.get(_sort_name) + sort_direction = _search_cls.sort_direction_options.get( + _sort_direction_name + ) + + return { + "page": page, + "size": size, + "sort": sort.get("fields"), + "sort_direction": sort_direction.get("fn"), + } diff --git a/invenio_rdm_records/oaiserver/services/uow.py b/invenio_rdm_records/oaiserver/services/uow.py index 87f80135b5..8e0d2613ae 100644 --- a/invenio_rdm_records/oaiserver/services/uow.py +++ b/invenio_rdm_records/oaiserver/services/uow.py @@ -31,4 +31,4 @@ def __init__(self, oai_set): self._oai_set = oai_set def on_register(self, uow): - db.session.remove(self._oai_set) + db.session.delete(self._oai_set) diff --git a/tests/conftest.py b/tests/conftest.py index b5b2c8d6ff..c677bccc44 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ # Copyright (C) 2019-2021 CERN. # Copyright (C) 2019-2022 Northwestern University. # Copyright (C) 2021 TU Wien. +# Copyright (C) 2022 Graz University of Technology. # # Invenio-RDM-Records is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -29,6 +30,7 @@ from invenio_access.permissions import superuser_access, system_identity from invenio_accounts.models import Role from invenio_accounts.testutils import login_user_via_session +from invenio_admin.permissions import action_admin_access from invenio_app.factory import create_app as _create_app from invenio_cache import current_cache from invenio_communities import current_communities @@ -538,6 +540,16 @@ def minimal_community(): } } +@pytest.fixture() +def minimal_oai_set(): + """Data for a minimal OAI-PMH set""" + return { + "name": "name", + "spec": "spec", + "search_pattern": "is_published:true", + "description": None, + } + @pytest.fixture() def parent(app, db): @@ -1015,6 +1027,27 @@ def superuser_identity(superuser_role_need): return identity +@pytest.fixture(scope="function") +def admin_role_need(db): + """Store 1 role with 'superuser-access' ActionNeed. + + WHY: This is needed because expansion of ActionNeed is + done on the basis of a User/Role being associated with that Need. + If no User/Role is associated with that Need (in the DB), the + permission is expanded to an empty list. + """ + role = Role(name="admin-access") + db.session.add(role) + + action_role = ActionRoles.create(action=action_admin_access, role=role) + db.session.add(action_role) + + db.session.commit() + + return action_role.need + + + @pytest.fixture() @mock.patch('arrow.utcnow') def embargoed_record( @@ -1074,3 +1107,21 @@ def community(running_app, curator, minimal_community): ) Community.index.refresh() return c + + +@pytest.fixture() +def admin(UserFixture, app, db, admin_role_need): + """Admin.""" + u = UserFixture( + email="admin@inveniosoftware.org", + password="admin", + ) + u.create(app, db) + + datastore = app.extensions["security"].datastore + _, role = datastore._prepare_role_modify_args(u.user, "admin-access") + + datastore.add_role_to_user(u.user, role) + db.session.commit() + return u + diff --git a/tests/resources/test_resources_oai.py b/tests/resources/test_resources_oai.py new file mode 100644 index 0000000000..2005818e0d --- /dev/null +++ b/tests/resources/test_resources_oai.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 Graz Univeresity of Technology. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""OAI-PMH resource level tests.""" + +from flask import current_app + + +def _create_set(client, data, headers, status_code): + """Send POST request.""" + s = client.post( + '/oaipmh/sets', + headers=headers, + json=data, + ) + assert s.status_code == status_code + return s + + +def _get_set(client, id, headers, status_code): + """Send GET request.""" + s = client.get( + f'/oaipmh/sets/{id}', + headers=headers, + ) + assert s.status_code == status_code + return s + + +def _update_set(client, id, data, headers, status_code): + """Send PUT request.""" + s = client.put( + f'/oaipmh/sets/{id}', + headers=headers, + json=data, + ) + assert s.status_code == status_code + return s + + +def _delete_set(client, id, headers, status_code): + """Send DELETE request.""" + s = client.delete( + f'/oaipmh/sets/{id}', + headers=headers, + ) + assert s.status_code == status_code + return s + + +def _search_sets(client, query, headers, status_code): + s = client.get( + '/oaipmh/sets', + headers=headers, + query_string=query, + ) + assert s.status_code == status_code + return s + + +def _search_formats(client, headers, status_code): + s = client.get( + '/oaipmh/formats', + headers=headers, + ) + assert s.status_code == status_code + return s + + +def test_create_set(client, admin, minimal_oai_set, headers): + """Create a set.""" + client = admin.login(client) + + # without description + s1 = _create_set(client, minimal_oai_set, headers, 201).json + assert s1["name"] == minimal_oai_set["name"] + assert s1["spec"] == minimal_oai_set["spec"] + assert s1["description"] == minimal_oai_set["description"] + assert s1["search_pattern"] == minimal_oai_set["search_pattern"] + + # with description + minimal_oai_set["spec"] = "s2" + minimal_oai_set["description"] = "description" + s2 = _create_set(client, minimal_oai_set, headers, 201).json + assert s2["name"] == minimal_oai_set["name"] + assert s2["spec"] == minimal_oai_set["spec"] + assert s2["description"] == minimal_oai_set["description"] + assert s2["search_pattern"] == minimal_oai_set["search_pattern"] + + +def test_create_set_duplicate(client, admin, minimal_oai_set, headers): + """Create two sets with same spec.""" + client = admin.login(client) + + _create_set(client, minimal_oai_set, headers, 201) + _create_set(client, minimal_oai_set, headers, 400) + + +def test_get_set(client, admin, minimal_oai_set, headers): + """Retrieve a set.""" + client = admin.login(client) + + # without description + created_set = _create_set(client, minimal_oai_set, headers, 201).json + retrieved_set = _get_set(client, created_set["id"], headers, 200).json + assert created_set["id"] == retrieved_set["id"] + assert created_set["name"] == retrieved_set["name"] + assert created_set["spec"] == retrieved_set["spec"] + assert created_set["description"] == retrieved_set["description"] + assert created_set["search_pattern"] == retrieved_set["search_pattern"] + + # with description + minimal_oai_set["spec"] = "s2" + minimal_oai_set["description"] = "description" + created_set = _create_set(client, minimal_oai_set, headers, 201).json + retrieved_set = _get_set(client, created_set["id"], headers, 200).json + assert created_set["id"] == retrieved_set["id"] + assert created_set["name"] == retrieved_set["name"] + assert created_set["spec"] == retrieved_set["spec"] + assert created_set["description"] == retrieved_set["description"] + + +def test_get_set_not_existing(client, headers): + """Retrieve not existing set.""" + _get_set(client, 9001, headers, 404).json + + +def test_update_set(client, admin, minimal_oai_set, headers): + """Update a set.""" + client = admin.login(client) + + s1 = _create_set(client, minimal_oai_set, headers, 201).json + + update = minimal_oai_set.copy() + del update["spec"] + update["name"] = "updated" + update["description"] = "updated" + update["search_pattern"] = "updated" + s1_updated = _update_set(client, s1["id"], update, headers, 200).json + + assert s1_updated["name"] == update["name"] + assert s1_updated["description"] == update["description"] + assert s1_updated["search_pattern"] == update["search_pattern"] + assert s1_updated["id"] == s1["id"] + assert s1_updated["spec"] == s1["spec"] + + +def test_delete_set(client, admin, minimal_oai_set, headers): + """Retrieve a set.""" + client = admin.login(client) + + s1 = _create_set(client, minimal_oai_set, headers, 201).json + _delete_set(client, s1["id"], headers, 204) + _get_set(client, s1["id"], headers, 404) + + +def test_delete_set_not_existing(client, admin, headers): + """Delete not existing set.""" + client = admin.login(client) + _delete_set(client, 9001, headers, 404).json + + +def test_search_sets(client, admin, minimal_oai_set, headers): + """Search sets.""" + client = admin.login(client) + + created_sets = [] + + num_sets = 4 + for i in range(num_sets): + minimal_oai_set["spec"] = minimal_oai_set["name"] = f"set_{i}" + s1 = _create_set(client, minimal_oai_set, headers, 201).json + created_sets.append(s1) + + search = _search_sets(client, {}, headers, 200).json + assert search["hits"]["total"] == num_sets + for i in range(num_sets): + assert search["hits"]["hits"][i]["spec"] == created_sets[i]["spec"] + assert "next" not in search["links"] + assert "prev" not in search["links"] + + search = _search_sets( + client, {"size": "1", "page": "2"}, headers, 200 + ).json + assert "next" in search["links"] + assert "prev" in search["links"] + + search = _search_sets( + client, {"sort_direction": "desc"}, headers, 200 + ).json + for i in range(num_sets): + assert ( + search["hits"]["hits"][num_sets - 1 - i]["spec"] + == created_sets[i]["spec"] + ) + + +def test_search_formats(client, headers): + """Retrieve metadata formats.""" + available_formats = current_app.config.get( + "OAISERVER_METADATA_FORMATS", {} + ) + search = _search_formats(client, headers, 200).json + assert search["hits"]["total"] == len(available_formats) + for hit in search["hits"]["hits"]: + assert hit["id"] in available_formats + assert hit["schema"] == available_formats[hit["id"]]["schema"] + assert hit["namespace"] == available_formats[hit["id"]]["namespace"]