diff --git a/swh/graphql/resolvers/visit_status.py b/swh/graphql/resolvers/visit_status.py index f7d0100..cfcf116 100644 --- a/swh/graphql/resolvers/visit_status.py +++ b/swh/graphql/resolvers/visit_status.py @@ -1,64 +1,69 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from swh.graphql.errors import NullableObjectError +from swh.graphql.utils import utils from swh.model.swhids import CoreSWHID, ObjectType from swh.storage.interface import PagedResult from .base_connection import BaseConnection from .base_node import BaseNode from .visit import BaseVisitNode class BaseVisitStatusNode(BaseNode): """ Base resolver for all the visit-status nodes """ @property def snapshotSWHID(self): # To support the schema naming convention if self._node.snapshot is None: return None return CoreSWHID(object_type=ObjectType.SNAPSHOT, object_id=self._node.snapshot) class LatestVisitStatusNode(BaseVisitStatusNode): """ Node resolver for a visit-status requested from a visit """ obj: BaseVisitNode def _get_node_data(self): # self.obj.origin is the origin URL return self.archive.get_latest_visit_status( origin_url=self.obj.origin, visit_id=self.obj.visitId, allowed_statuses=self.kwargs.get("allowedStatuses"), require_snapshot=self.kwargs.get("requireSnapshot"), ) def _handle_node_errors(self) -> None: # This object can be null if self._node is None: raise NullableObjectError("") class VisitStatusConnection(BaseConnection): """ Connection resolver for the visit-status objects in a visit """ obj: BaseVisitNode _node_class = BaseVisitStatusNode def _get_paged_result(self) -> PagedResult: # self.obj.origin is the origin URL return self.archive.get_visit_status( self.obj.origin, self.obj.visitId, after=self._get_after_arg(), first=self._get_first_arg(), ) + + def _get_index_cursor(self, index: int, node: BaseVisitStatusNode): + # Visit status is using a different cursor, hence the override + return utils.get_encoded_cursor(utils.get_formatted_date(node.date)) diff --git a/swh/graphql/tests/data.py b/swh/graphql/tests/data.py index d75c9e5..dc7e3f4 100644 --- a/swh/graphql/tests/data.py +++ b/swh/graphql/tests/data.py @@ -1,138 +1,158 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information +import datetime + from swh.model.model import ( Directory, DirectoryEntry, ObjectType, + OriginVisitStatus, Release, Revision, RevisionType, ) from swh.model.tests import swh_model_data +UTC = datetime.timezone.utc + def populate_search_data(search): search.origin_update({"url": origin.url} for origin in get_origins()) def get_origins(): return swh_model_data.ORIGINS def get_visits(): return swh_model_data.ORIGIN_VISITS def get_visit_status(): return swh_model_data.ORIGIN_VISIT_STATUSES def get_snapshots(): return swh_model_data.SNAPSHOTS def get_releases(): return swh_model_data.RELEASES def get_revisions(): return swh_model_data.REVISIONS def get_contents(): return swh_model_data.CONTENTS def get_directories(): return swh_model_data.DIRECTORIES def get_releases_with_target(): """ GraphQL will not return a target object unless the target id is present in the DB. Return release objects with real targets instead of dummy targets in swh.model.tests.swh_model_data """ with_revision = Release( name=b"v0.0.1", target_type=ObjectType.REVISION, target=get_revisions()[0].id, message=b"foo", synthetic=False, ) with_release = Release( name=b"v0.0.1", target_type=ObjectType.RELEASE, target=get_releases()[0].id, message=b"foo", synthetic=False, ) with_directory = Release( name=b"v0.0.1", target_type=ObjectType.DIRECTORY, target=get_directories()[0].id, message=b"foo", synthetic=False, ) with_content = Release( name=b"v0.0.1", target_type=ObjectType.CONTENT, target=get_contents()[0].sha1_git, message=b"foo", synthetic=False, ) return [with_revision, with_release, with_directory, with_content] def get_revisions_with_parents(): """ Revisions with real revisions as parents """ return [ Revision( message=b"hello", date=swh_model_data.DATES[0], committer=swh_model_data.COMMITTERS[0], author=swh_model_data.COMMITTERS[0], committer_date=swh_model_data.DATES[0], type=RevisionType.GIT, directory=b"\x01" * 20, synthetic=False, parents=(get_revisions()[0].id, get_revisions()[1].id), ) ] def get_directories_with_nested_path(): return [ Directory( entries=( DirectoryEntry( name=b"sub-dir", perms=0o644, type="dir", target=get_directories()[1].id, ), ) ) ] +def get_visit_with_multiple_status(): + return [ + OriginVisitStatus( + origin=get_origins()[0].url, + date=datetime.datetime(2014, 5, 7, 4, 20, 39, 432222, tzinfo=UTC), + visit=1, + type="git", + status="ongoing", + snapshot=None, + metadata=None, + ) + ] + + GRAPHQL_EXTRA_TEST_OBJECTS = { "release": get_releases_with_target(), "revision": get_revisions_with_parents(), "directory": get_directories_with_nested_path(), + "origin_visit_status": get_visit_with_multiple_status(), } def populate_dummy_data(storage): for object_type, objects in swh_model_data.TEST_OBJECTS.items(): method = getattr(storage, object_type + "_add") method(objects) for object_type, objects in GRAPHQL_EXTRA_TEST_OBJECTS.items(): method = getattr(storage, object_type + "_add") method(objects) diff --git a/swh/graphql/tests/functional/test_visit_node.py b/swh/graphql/tests/functional/test_visit_node.py index 71073f5..d7a0a57 100644 --- a/swh/graphql/tests/functional/test_visit_node.py +++ b/swh/graphql/tests/functional/test_visit_node.py @@ -1,164 +1,164 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import pytest from ..data import get_origins from .utils import assert_missing_object, get_query_response @pytest.mark.parametrize("origin", get_origins()) def test_get_visit(client, storage, origin): query_str = """ { visit(originUrl: "%s", visitId: %s) { visitId date type latestStatus { status date type snapshot { swhid } } statuses { nodes { status } } } } """ visits_and_statuses = storage.origin_visit_get_with_statuses(origin.url).results for vws in visits_and_statuses: visit = vws.visit statuses = vws.statuses data, _ = get_query_response(client, query_str % (origin.url, visit.visit)) assert data["visit"] == { "visitId": visit.visit, "type": visit.type, "date": visit.date.isoformat(), "latestStatus": { "date": statuses[-1].date.isoformat(), "type": statuses[-1].type, "status": statuses[-1].status, "snapshot": ({"swhid": f"swh:1:snp:{statuses[-1].snapshot.hex()}"}) if statuses[-1].snapshot else None, }, "statuses": {"nodes": [{"status": status.status} for status in statuses]}, } def test_invalid_get_visit(client): query_str = """ { visit(originUrl: "http://example.com/forge1", visitId: 3) { type } } """ assert_missing_object(client, query_str, "visit") def test_get_latest_visit_status_filter_by_status_return_null(client): query_str = """ { visit(originUrl: "%s", visitId: %s) { visitId date type latestStatus(allowedStatuses: [full]) { status } } } """ % ( get_origins()[0].url, 1, ) data, err = get_query_response(client, query_str) assert err is None assert data == { "visit": { "date": "2013-05-07T04:20:39.369271+00:00", "latestStatus": None, "type": "git", "visitId": 1, } } def test_get_latest_visit_status_filter_by_type(client): query_str = """ { visit(originUrl: "%s", visitId: %s) { visitId date type latestStatus(allowedStatuses: [ongoing]) { status date } } } """ % ( get_origins()[0].url, 1, ) data, err = get_query_response(client, query_str) assert err is None assert data == { "visit": { "date": "2013-05-07T04:20:39.369271+00:00", "latestStatus": { - "date": "2013-05-07T04:20:39.432222+00:00", + "date": "2014-05-07T04:20:39.432222+00:00", "status": "ongoing", }, "type": "git", "visitId": 1, } } def test_get_latest_visit_status_filter_by_snapshot(client): query_str = """ { visit(originUrl: "%s", visitId: %s) { visitId date type latestStatus(requireSnapshot: true) { status date snapshot { swhid } } } } """ % ( get_origins()[1].url, 2, ) data, err = get_query_response(client, query_str) assert err is None assert data == { "visit": { "date": "2015-11-27T17:20:39+00:00", "latestStatus": { "date": "2015-11-27T17:22:18+00:00", "snapshot": { "swhid": "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" }, "status": "partial", }, "type": "hg", "visitId": 2, } } diff --git a/swh/graphql/tests/functional/test_visit_status.py b/swh/graphql/tests/functional/test_visit_status.py index dbf40bd..13c81ba 100644 --- a/swh/graphql/tests/functional/test_visit_status.py +++ b/swh/graphql/tests/functional/test_visit_status.py @@ -1,43 +1,103 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import pytest -from ..data import get_visit_status, get_visits +from ..data import get_origins, get_visit_status, get_visits from .utils import get_query_response @pytest.mark.parametrize( "visit, visit_status", list(zip(get_visits(), get_visit_status())) ) def test_get_visit_status(client, visit, visit_status): query_str = """ { visit(originUrl: "%s", visitId: %s) { statuses(first: 3) { nodes { status date type snapshot { swhid } } } } } """ % ( visit.origin, visit.visit, ) data, _ = get_query_response(client, query_str) assert data["visit"]["statuses"]["nodes"][0] == { "date": visit_status.date.isoformat(), "snapshot": {"swhid": f"swh:1:snp:{visit_status.snapshot.hex()}"} if visit_status.snapshot is not None else None, "status": visit_status.status, "type": visit_status.type, } + + +def test_visit_status_pagination(client): + # visit status is using a different cursor, hence separate test + query_str = """ + { + visit(originUrl: "%s", visitId: %s) { + statuses(first: 1) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + status + } + } + } + } + } + """ % ( + get_origins()[0].url, + 1, + ) + data, _ = get_query_response(client, query_str) + # request again with the endcursor + end_cursor = data["visit"]["statuses"]["pageInfo"]["endCursor"] + query_str = """ + { + visit(originUrl: "%s", visitId: %s) { + statuses(first: 1, after: "%s") { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + status + } + } + } + } + } + """ % ( + get_origins()[0].url, + 1, + end_cursor, + ) + data, _ = get_query_response(client, query_str) + assert data["visit"]["statuses"] == { + "edges": [ + { + "cursor": "MjAxNC0wNS0wN1QwNDoyMDozOS40MzIyMjIrMDA6MDA=", + "node": {"status": "ongoing"}, + } + ], + "pageInfo": {"endCursor": None, "hasNextPage": False}, + }