diff --git a/setup.cfg b/setup.cfg index d51fba95..1cc50626 100644 --- a/setup.cfg +++ b/setup.cfg @@ -71,7 +71,6 @@ tests = invenio-db[postgresql,mysql]>=2.0.0,<3.0.0 invenio-files-rest>=3.0.0,<4.0.0 isort>=4.2.2 - mock>=2.0.0 pytest-black-ng>=0.4.0 pytest-invenio>=3.0.0,<4.0.0 pytest-mock>=2.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index e2be18a5..1129eb45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,55 +1,56 @@ # -*- coding: utf-8 -*- # -# This file is part of Invenio. # Copyright (C) 2023-2025 CERN. -# Copyright (C) 2025 Graz University of Technology. # -# Invenio is free software; you can redistribute it -# and/or modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of the -# License, or (at your option) any later version. -# -# Invenio is distributed in the hope that it will be -# useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Invenio; if not, write to the -# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, -# MA 02111-1307, USA. -# -# In applying this license, CERN does not -# waive the privileges and immunities granted to it by virtue of its status -# as an Intergovernmental Organization or submit itself to any jurisdiction. - +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. """Pytest configuration.""" from __future__ import absolute_import, print_function from collections import namedtuple +from typing import Any -import github3 import pytest from invenio_app.factory import create_api -from invenio_oauthclient.contrib.github import REMOTE_APP as GITHUB_REMOTE_APP -from invenio_oauthclient.contrib.github import REMOTE_REST_APP as GITHUB_REMOTE_REST_APP from invenio_oauthclient.models import RemoteToken from invenio_oauthclient.proxies import current_oauthclient -from mock import MagicMock, patch + +from invenio_vcs.contrib.github import GitHubProviderFactory +from invenio_vcs.contrib.gitlab import GitLabProviderFactory +from invenio_vcs.generic_models import ( + GenericContributor, + GenericOwner, + GenericOwnerType, + GenericRelease, + GenericRepository, + GenericUser, + GenericWebhook, +) +from invenio_vcs.providers import RepositoryServiceProvider +from invenio_vcs.service import VCSService +from invenio_vcs.utils import utcnow +from tests.contrib_fixtures.github import GitHubPatcher +from tests.contrib_fixtures.gitlab import GitLabPatcher +from tests.contrib_fixtures.patcher import TestProviderPatcher from .fixtures import ( - ZIPBALL, - TestGithubRelease, - github_file_contents, - github_repo_metadata, - github_user_metadata, + TestVCSRelease, ) @pytest.fixture(scope="module") def app_config(app_config): """Test app config.""" + vcs_github = GitHubProviderFactory( + base_url="https://github.com", + webhook_receiver_url="http://localhost:5000/api/receivers/github/events/?access_token={token}", + ) + vcs_gitlab = GitLabProviderFactory( + base_url="https://gitlab.com", + webhook_receiver_url="http://localhost:5000/api/receivers/gitlab/events/?access_token={token}", + ) + app_config.update( # HTTPretty doesn't play well with Redis. # See gabrielfalcao/HTTPretty#110 @@ -63,21 +64,24 @@ def app_config(app_config): consumer_key="changeme", consumer_secret="changeme", ), - GITHUB_SHARED_SECRET="changeme", - GITHUB_INSECURE_SSL=False, - GITHUB_INTEGRATION_ENABLED=True, - GITHUB_METADATA_FILE=".invenio.json", - GITHUB_WEBHOOK_RECEIVER_URL="http://localhost:5000/api/receivers/github/events/?access_token={token}", - GITHUB_WEBHOOK_RECEIVER_ID="github", - GITHUB_RELEASE_CLASS=TestGithubRelease, + GITLAB_APP_CREDENTIALS=dict( + consumer_key="changeme", + consumer_secret="changeme", + ), + VCS_RELEASE_CLASS=TestVCSRelease, + VCS_PROVIDERS=[vcs_github, vcs_gitlab], + # TODO: delete this to avoid duplication + VCS_INTEGRATION_ENABLED=True, LOGIN_DISABLED=False, OAUTHLIB_INSECURE_TRANSPORT=True, OAUTH2_CACHE_TYPE="simple", OAUTHCLIENT_REMOTE_APPS=dict( - github=GITHUB_REMOTE_APP, + github=vcs_github.remote_config, + gitlab=vcs_gitlab.remote_config, ), OAUTHCLIENT_REST_REMOTE_APPS=dict( - github=GITHUB_REMOTE_REST_APP, + github=vcs_github.remote_config, + gitlab=vcs_gitlab.remote_config, ), SECRET_KEY="test_key", SERVER_NAME="testserver.localdomain", @@ -98,9 +102,6 @@ def app_config(app_config): FILES_REST_DEFAULT_STORAGE_CLASS="L", THEME_FRONTPAGE=False, ) - app_config["OAUTHCLIENT_REMOTE_APPS"]["github"]["params"]["request_token_params"][ - "scope" - ] = "user:email,admin:repo_hook,read:org" return app_config @@ -131,10 +132,10 @@ def running_app(app, location, cache): @pytest.fixture() -def test_user(app, db, github_remote_app): +def test_user(app, db, remote_apps): """Creates a test user. - Links the user to a github RemoteToken. + Links the user to a VCS RemoteToken. """ datastore = app.extensions["security"].datastore user = datastore.create_user( @@ -142,33 +143,29 @@ def test_user(app, db, github_remote_app): password="tester", ) - # Create GitHub link for user - token = RemoteToken.get(user.id, github_remote_app.consumer_key) - if not token: - RemoteToken.create( - user.id, - github_remote_app.consumer_key, - "test", - "", - ) + # Create provider links for user + for app in remote_apps: + token = RemoteToken.get(user.id, app.consumer_key) + if not token: + # This auto-creates the missing RemoteAccount + RemoteToken.create( + user.id, + app.consumer_key, + "test", + "", + ) + db.session.commit() return user @pytest.fixture() -def github_remote_app(): - """Returns github remote app.""" - return current_oauthclient.oauth.remote_apps["github"] - - -@pytest.fixture() -def remote_token(test_user, github_remote_app): - """Returns github RemoteToken for user.""" - token = RemoteToken.get( - test_user.id, - github_remote_app.consumer_key, - ) - return token +def remote_apps(): + """An example list of configured OAuth apps.""" + return [ + current_oauthclient.oauth.remote_apps["github"], + current_oauthclient.oauth.remote_apps["gitlab"], + ] @pytest.fixture @@ -190,121 +187,153 @@ def tester_id(test_user): @pytest.fixture() -def test_repo_data_one(): - """Test repository.""" - return {"name": "repo-1", "id": 1} +def test_generic_repositories(): + """Provider-common dataset of test repositories.""" + return [ + GenericRepository( + id="1", + full_name="repo-1", + default_branch="main", + description="Lorem ipsum", + license_spdx="MIT", + ), + GenericRepository( + id="2", + full_name="repo-2", + default_branch="main", + description="Lorem ipsum", + license_spdx="MIT", + ), + GenericRepository( + id="3", + full_name="repo-3", + default_branch="main", + description="Lorem ipsum", + license_spdx="MIT", + ), + ] + + +@pytest.fixture() +def test_generic_contributors(): + """Provider-common dataset of test contributors (same for all repos).""" + return [ + GenericContributor( + id="1", username="user1", company="Lorem", display_name="Lorem" + ), + GenericContributor( + id="2", username="user2", contributions_count=10, display_name="Lorem" + ), + ] + + +@pytest.fixture() +def test_collaborators(): + """Provider-common dataset of test collaborators (same for all repos). + + We don't have a built-in generic type for this so we'll use a dictionary. + """ + return [ + {"id": "1", "username": "user1", "admin": True}, + {"id": "2", "username": "user2", "admin": False}, + ] @pytest.fixture() -def test_repo_data_two(): - """Test repository.""" - return {"name": "repo-2", "id": 2} +def test_generic_webhooks(): + """Provider-common dataset of test webhooks (same for all repos).""" + return [ + GenericWebhook(id="1", repository_id="1", url="https://example.com"), + GenericWebhook(id="2", repository_id="2", url="https://example.com"), + ] @pytest.fixture() -def test_repo_data_three(): - """Test repository.""" - return {"name": "arepo", "id": 3} +def test_generic_user(): + """Provider-common user to own the repositories.""" + return GenericUser(id="1", username="user1", display_name="Test User") @pytest.fixture() -def github_api( - running_app, - db, - test_repo_data_one, - test_repo_data_two, - test_repo_data_three, - test_user, -): - """Github API mock.""" - mock_api = MagicMock() - mock_api.session = MagicMock() - mock_api.me.return_value = github3.users.User( - github_user_metadata(login="auser", email="auser@inveniosoftware.org"), - mock_api.session, +def test_generic_owner(test_generic_user: GenericUser): + """GenericOwner representation of the test generic user.""" + return GenericOwner( + test_generic_user.id, + test_generic_user.username, + GenericOwnerType.Person, + display_name=test_generic_user.display_name, ) - repo_1 = github3.repos.Repository( - github_repo_metadata( - "auser", test_repo_data_one["name"], test_repo_data_one["id"] - ), - mock_api.session, - ) - repo_1.hooks = MagicMock(return_value=[]) - repo_1.file_contents = MagicMock(return_value=None) - # Mock hook creation to retun the hook id '12345' - hook_instance = MagicMock() - hook_instance.id = 12345 - repo_1.create_hook = MagicMock(return_value=hook_instance) - - repo_2 = github3.repos.Repository( - github_repo_metadata( - "auser", test_repo_data_two["name"], test_repo_data_two["id"] - ), - mock_api.session, + +@pytest.fixture() +def test_generic_release(): + """Provider-common example release.""" + return GenericRelease( + id="1", + tag_name="v1.0", + created_at=utcnow(), + name="Example release", + body="Lorem ipsum dolor sit amet", + published_at=utcnow(), + tarball_url="https://example.com/v1.0.tar", + zipball_url="https://example.com/v1.0.zip", ) - repo_2.hooks = MagicMock(return_value=[]) - repo_2.create_hook = MagicMock(return_value=hook_instance) - file_path = "test.py" +@pytest.fixture() +def test_file(): + """Provider-common example file within a repository (no generic interface available for this).""" + return {"path": "test.py", "content": "test"} - def mock_file_content(): - # File contents mocking - owner = "auser" - repo = test_repo_data_two["name"] - ref = "" - # Dummy data to be encoded as the file contents - data = "dummy".encode("ascii") - return github_file_contents(owner, repo, file_path, ref, data) +_provider_patchers: list[type[TestProviderPatcher]] = [GitHubPatcher, GitLabPatcher] - file_data = mock_file_content() - def mock_file_contents(path, ref=None): - if path == file_path: - # Mock github3.contents.Content with file_data - return MagicMock(decoded=file_data) - return None +def provider_id(p: type[TestProviderPatcher]): + """Extract the provider ID to use as the test case ID.""" + return p.provider_factory().id - repo_2.file_contents = MagicMock(side_effect=mock_file_contents) - repo_3 = github3.repos.Repository( - github_repo_metadata( - "auser", test_repo_data_three["name"], test_repo_data_three["id"] - ), - mock_api.session, +@pytest.fixture(params=_provider_patchers, ids=provider_id) +def vcs_provider( + request: pytest.FixtureRequest, + test_user, + test_generic_repositories: list[GenericRepository], + test_generic_contributors: list[GenericContributor], + test_collaborators: list[dict[str, Any]], + test_generic_webhooks: list[GenericWebhook], + test_generic_user: GenericUser, + test_file: dict[str, Any], +): + """Call the patcher for the provider and run the test case 'inside' its patch context.""" + patcher_class: type[TestProviderPatcher] = request.param + patcher = patcher_class(test_user) + # The patch call returns a generator that yields the provider within the patch context. + # Use yield from to delegate to the patcher's generator, ensuring tests run within the patch context. + yield from patcher.patch( + test_generic_repositories, + test_generic_contributors, + test_collaborators, + test_generic_webhooks, + test_generic_user, + test_file, + ) + + +@pytest.fixture() +def vcs_service(vcs_provider: RepositoryServiceProvider): + """Return an initialised (but not synced) service object for a provider.""" + svc = VCSService(vcs_provider) + svc.init_account() + return svc + + +@pytest.fixture() +def provider_patcher(vcs_provider: RepositoryServiceProvider): + """Return the raw patcher object corresponding to the current test's provider.""" + for patcher in _provider_patchers: + if patcher.provider_factory().id == vcs_provider.factory.id: + return patcher + raise ValueError( + f"Patcher corresponding to ID {vcs_provider.factory.id} not found." ) - repo_3.hooks = MagicMock(return_value=[]) - repo_3.file_contents = MagicMock(return_value=None) - - repos = {1: repo_1, 2: repo_2, 3: repo_3} - repos_by_name = {r.full_name: r for r in repos.values()} - mock_api.repositories.return_value = repos.values() - - def mock_repo_with_id(id): - return repos.get(id) - - def mock_repo_by_name(owner, name): - return repos_by_name.get("/".join((owner, name))) - - def mock_head_status_by_repo_url(url, **kwargs): - url_specific_refs_tags = ( - "https://github.com/auser/repo-2/zipball/refs/tags/v1.0-tag-and-branch" - ) - if url.endswith("v1.0-tag-and-branch") and url != url_specific_refs_tags: - return MagicMock( - status_code=300, links={"alternate": {"url": url_specific_refs_tags}} - ) - else: - return MagicMock(status_code=200, url=url) - - mock_api.repository_with_id.side_effect = mock_repo_with_id - mock_api.repository.side_effect = mock_repo_by_name - mock_api.markdown.side_effect = lambda x: x - mock_api.session.head.side_effect = mock_head_status_by_repo_url - mock_api.session.get.return_value = MagicMock(raw=ZIPBALL()) - - with patch("invenio_github.api.GitHubAPI.api", new=mock_api): - with patch("invenio_github.api.GitHubAPI._sync_hooks"): - yield mock_api diff --git a/tests/contrib_fixtures/github.py b/tests/contrib_fixtures/github.py new file mode 100644 index 00000000..c30c8215 --- /dev/null +++ b/tests/contrib_fixtures/github.py @@ -0,0 +1,813 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# Invenio-Github is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Fixture test impl for GitHub.""" + +import os +from base64 import b64encode +from io import BytesIO +from typing import Any, Iterator +from unittest.mock import MagicMock, patch +from zipfile import ZipFile + +import github3 +import github3.repos +import github3.repos.hook +import github3.users + +from invenio_vcs.contrib.github import GitHubProviderFactory +from invenio_vcs.generic_models import ( + GenericContributor, + GenericOwner, + GenericRelease, + GenericRepository, + GenericUser, + GenericWebhook, +) +from invenio_vcs.providers import ( + RepositoryServiceProvider, + RepositoryServiceProviderFactory, +) +from tests.contrib_fixtures.patcher import TestProviderPatcher + + +def github_user_metadata( + id: int, display_name: str | None, login, email=None, bio=True +): + """Github user fixture generator.""" + username = login + + user = { + "avatar_url": "https://avatars.githubusercontent.com/u/7533764?", + "collaborators": 0, + "created_at": "2014-05-09T12:26:44Z", + "disk_usage": 0, + "events_url": "https://api.github.com/users/%s/events{/privacy}" % username, + "followers": 0, + "followers_url": "https://api.github.com/users/%s/followers" % username, + "following": 0, + "following_url": "https://api.github.com/users/%s/" + "following{/other_user}" % username, + "gists_url": "https://api.github.com/users/%s/gists{/gist_id}" % username, + "gravatar_id": "12345678", + "html_url": "https://github.com/%s" % username, + "id": id, + "login": "%s" % username, + "organizations_url": "https://api.github.com/users/%s/orgs" % username, + "owned_private_repos": 0, + "plan": { + "collaborators": 0, + "name": "free", + "private_repos": 0, + "space": 307200, + }, + "private_gists": 0, + "public_gists": 0, + "public_repos": 0, + "received_events_url": "https://api.github.com/users/%s/" + "received_events" % username, + "repos_url": "https://api.github.com/users/%s/repos" % username, + "site_admin": False, + "starred_url": "https://api.github.com/users/%s/" + "starred{/owner}{/repo}" % username, + "subscriptions_url": "https://api.github.com/users/%s/" + "subscriptions" % username, + "total_private_repos": 0, + "type": "User", + "updated_at": "2014-05-09T12:26:44Z", + "url": "https://api.github.com/users/%s" % username, + "hireable": False, + "location": "Geneve", + } + + if bio: + user.update( + { + "bio": "Software Engineer at CERN", + "blog": "http://www.cern.ch", + "company": "CERN", + "name": display_name, + } + ) + + if email is not None: + user.update( + { + "email": email, + } + ) + + return user + + +def github_repo_metadata( + owner_username: str, + owner_id: int, + repo_name: str, + repo_id: int, + default_branch: str, +): + """Github repository fixture generator.""" + repo_url = "%s/%s" % (owner_username, repo_name) + + return { + "archive_url": "https://api.github.com/repos/%s/" + "{archive_format}{/ref}" % repo_url, + "assignees_url": "https://api.github.com/repos/%s/" + "assignees{/user}" % repo_url, + "blobs_url": "https://api.github.com/repos/%s/git/blobs{/sha}" % repo_url, + "branches_url": "https://api.github.com/repos/%s/" + "branches{/branch}" % repo_url, + "clone_url": "https://github.com/%s.git" % repo_url, + "collaborators_url": "https://api.github.com/repos/%s/" + "collaborators{/collaborator}" % repo_url, + "comments_url": "https://api.github.com/repos/%s/" + "comments{/number}" % repo_url, + "commits_url": "https://api.github.com/repos/%s/commits{/sha}" % repo_url, + "compare_url": "https://api.github.com/repos/%s/compare/" + "{base}...{head}" % repo_url, + "contents_url": "https://api.github.com/repos/%s/contents/{+path}" % repo_url, + "contributors_url": "https://api.github.com/repos/%s/contributors" % repo_url, + "created_at": "2012-10-29T10:24:02Z", + "default_branch": default_branch, + "description": "", + "downloads_url": "https://api.github.com/repos/%s/downloads" % repo_url, + "events_url": "https://api.github.com/repos/%s/events" % repo_url, + "fork": False, + "forks": 0, + "forks_count": 0, + "forks_url": "https://api.github.com/repos/%s/forks" % repo_url, + "full_name": repo_url, + "git_commits_url": "https://api.github.com/repos/%s/git/" + "commits{/sha}" % repo_url, + "git_refs_url": "https://api.github.com/repos/%s/git/refs{/sha}" % repo_url, + "git_tags_url": "https://api.github.com/repos/%s/git/tags{/sha}" % repo_url, + "git_url": "git://github.com/%s.git" % repo_url, + "has_downloads": True, + "has_issues": True, + "has_wiki": True, + "homepage": None, + "hooks_url": "https://api.github.com/repos/%s/hooks" % repo_url, + "html_url": "https://github.com/%s" % repo_url, + "id": repo_id, + "issue_comment_url": "https://api.github.com/repos/%s/issues/" + "comments/{number}" % repo_url, + "issue_events_url": "https://api.github.com/repos/%s/issues/" + "events{/number}" % repo_url, + "issues_url": "https://api.github.com/repos/%s/issues{/number}" % repo_url, + "keys_url": "https://api.github.com/repos/%s/keys{/key_id}" % repo_url, + "labels_url": "https://api.github.com/repos/%s/labels{/name}" % repo_url, + "language": None, + "languages_url": "https://api.github.com/repos/%s/languages" % repo_url, + "merges_url": "https://api.github.com/repos/%s/merges" % repo_url, + "milestones_url": "https://api.github.com/repos/%s/" + "milestones{/number}" % repo_url, + "mirror_url": None, + "name": "altantis-conf", + "notifications_url": "https://api.github.com/repos/%s/" + "notifications{?since,all,participating}", + "open_issues": 0, + "open_issues_count": 0, + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/1234?", + "events_url": "https://api.github.com/users/%s/" + "events{/privacy}" % owner_username, + "followers_url": "https://api.github.com/users/%s/followers" + % owner_username, + "following_url": "https://api.github.com/users/%s/" + "following{/other_user}" % owner_username, + "gists_url": "https://api.github.com/users/%s/gists{/gist_id}" + % owner_username, + "gravatar_id": "1234", + "html_url": "https://github.com/%s" % owner_username, + "id": owner_id, + "login": "%s" % owner_username, + "organizations_url": "https://api.github.com/users/%s/orgs" + % owner_username, + "received_events_url": "https://api.github.com/users/%s/" + "received_events" % owner_username, + "repos_url": "https://api.github.com/users/%s/repos" % owner_username, + "site_admin": False, + "starred_url": "https://api.github.com/users/%s/" + "starred{/owner}{/repo}" % owner_username, + "subscriptions_url": "https://api.github.com/users/%s/" + "subscriptions" % owner_username, + "type": "User", + "url": "https://api.github.com/users/%s" % owner_username, + }, + "permissions": {"admin": True, "pull": True, "push": True}, + "private": False, + "pulls_url": "https://api.github.com/repos/%s/pulls{/number}" % repo_url, + "pushed_at": "2012-10-29T10:28:08Z", + "releases_url": "https://api.github.com/repos/%s/releases{/id}" % repo_url, + "size": 104, + "ssh_url": "git@github.com:%s.git" % repo_url, + "stargazers_count": 0, + "stargazers_url": "https://api.github.com/repos/%s/stargazers" % repo_url, + "statuses_url": "https://api.github.com/repos/%s/statuses/{sha}" % repo_url, + "subscribers_url": "https://api.github.com/repos/%s/subscribers" % repo_url, + "subscription_url": "https://api.github.com/repos/%s/subscription" % repo_url, + "svn_url": "https://github.com/%s" % repo_url, + "tags_url": "https://api.github.com/repos/%s/tags" % repo_url, + "teams_url": "https://api.github.com/repos/%s/teams" % repo_url, + "trees_url": "https://api.github.com/repos/%s/git/trees{/sha}" % repo_url, + "updated_at": "2013-10-25T11:30:04Z", + "url": "https://api.github.com/repos/%s" % repo_url, + "watchers": 0, + "watchers_count": 0, + "deployments_url": "https://api.github.com/repos/%s/deployments" % repo_url, + "archived": False, + "has_pages": False, + "has_projects": False, + "network_count": 0, + "subscribers_count": 0, + } + + +def github_zipball(): + """Github repository ZIP fixture.""" + memfile = BytesIO() + zipfile = ZipFile(memfile, "w") + zipfile.writestr("test.txt", "hello world") + zipfile.close() + memfile.seek(0) + return memfile + + +def github_webhook_payload( + sender, repo, repo_id, default_branch: str, tag: str = "v1.0" +): + """Github payload fixture generator.""" + c = dict(repo=repo, user=sender, url="%s/%s" % (sender, repo), id="4321", tag=tag) + + return { + "action": "published", + "release": { + "url": "https://api.github.com/repos/%(url)s/releases/%(id)s" % c, + "assets_url": "https://api.github.com/repos/%(url)s/releases/" + "%(id)s/assets" % c, + "upload_url": "https://uploads.github.com/repos/%(url)s/" + "releases/%(id)s/assets{?name}" % c, + "html_url": "https://github.com/%(url)s/releases/tag/%(tag)s" % c, + "id": int(c["id"]), + "tag_name": c["tag"], + "target_commitish": default_branch, + "name": "Release name", + "body": "", + "draft": False, + "author": { + "login": "%(user)s" % c, + "id": 1698163, + "avatar_url": "https://avatars.githubusercontent.com/u/12345", + "gravatar_id": "12345678", + "url": "https://api.github.com/users/%(user)s" % c, + "html_url": "https://github.com/%(user)s" % c, + "followers_url": "https://api.github.com/users/%(user)s/" + "followers" % c, + "following_url": "https://api.github.com/users/%(user)s/" + "following{/other_user}" % c, + "gists_url": "https://api.github.com/users/%(user)s/" + "gists{/gist_id}" % c, + "starred_url": "https://api.github.com/users/%(user)s/" + "starred{/owner}{/repo}" % c, + "subscriptions_url": "https://api.github.com/users/%(user)s/" + "subscriptions" % c, + "organizations_url": "https://api.github.com/users/%(user)s/" + "orgs" % c, + "repos_url": "https://api.github.com/users/%(user)s/repos" % c, + "events_url": "https://api.github.com/users/%(user)s/" + "events{/privacy}" % c, + "received_events_url": "https://api.github.com/users/" + "%(user)s/received_events" % c, + "type": "User", + "site_admin": False, + }, + "prerelease": False, + "created_at": "2014-02-26T08:13:42Z", + "published_at": "2014-02-28T13:55:32Z", + "assets": [], + "tarball_url": "https://api.github.com/repos/%(url)s/" + "tarball/%(tag)s" % c, + "zipball_url": "https://api.github.com/repos/%(url)s/" + "zipball/%(tag)s" % c, + }, + "repository": { + "id": repo_id, + "name": repo, + "full_name": "%(url)s" % c, + "owner": { + "login": "%(user)s" % c, + "id": 1698163, + "avatar_url": "https://avatars.githubusercontent.com/u/" "1698163", + "gravatar_id": "bbc951080061fc48cae0279d27f3c015", + "url": "https://api.github.com/users/%(user)s" % c, + "html_url": "https://github.com/%(user)s" % c, + "followers_url": "https://api.github.com/users/%(user)s/" + "followers" % c, + "following_url": "https://api.github.com/users/%(user)s/" + "following{/other_user}" % c, + "gists_url": "https://api.github.com/users/%(user)s/" + "gists{/gist_id}" % c, + "starred_url": "https://api.github.com/users/%(user)s/" + "starred{/owner}{/repo}" % c, + "subscriptions_url": "https://api.github.com/users/%(user)s/" + "subscriptions" % c, + "organizations_url": "https://api.github.com/users/%(user)s/" + "orgs" % c, + "repos_url": "https://api.github.com/users/%(user)s/" "repos" % c, + "events_url": "https://api.github.com/users/%(user)s/" + "events{/privacy}" % c, + "received_events_url": "https://api.github.com/users/" + "%(user)s/received_events" % c, + "type": "User", + "site_admin": False, + }, + "private": False, + "html_url": "https://github.com/%(url)s" % c, + "description": "Repo description.", + "fork": True, + "url": "https://api.github.com/repos/%(url)s" % c, + "forks_url": "https://api.github.com/repos/%(url)s/forks" % c, + "keys_url": "https://api.github.com/repos/%(url)s/" "keys{/key_id}" % c, + "collaborators_url": "https://api.github.com/repos/%(url)s/" + "collaborators{/collaborator}" % c, + "teams_url": "https://api.github.com/repos/%(url)s/teams" % c, + "hooks_url": "https://api.github.com/repos/%(url)s/hooks" % c, + "issue_events_url": "https://api.github.com/repos/%(url)s/" + "issues/events{/number}" % c, + "events_url": "https://api.github.com/repos/%(url)s/events" % c, + "assignees_url": "https://api.github.com/repos/%(url)s/" + "assignees{/user}" % c, + "branches_url": "https://api.github.com/repos/%(url)s/" + "branches{/branch}" % c, + "tags_url": "https://api.github.com/repos/%(url)s/tags" % c, + "blobs_url": "https://api.github.com/repos/%(url)s/git/" "blobs{/sha}" % c, + "git_tags_url": "https://api.github.com/repos/%(url)s/git/" + "tags{/sha}" % c, + "git_refs_url": "https://api.github.com/repos/%(url)s/git/" + "refs{/sha}" % c, + "trees_url": "https://api.github.com/repos/%(url)s/git/" "trees{/sha}" % c, + "statuses_url": "https://api.github.com/repos/%(url)s/" + "statuses/{sha}" % c, + "languages_url": "https://api.github.com/repos/%(url)s/" "languages" % c, + "stargazers_url": "https://api.github.com/repos/%(url)s/" "stargazers" % c, + "contributors_url": "https://api.github.com/repos/%(url)s/" + "contributors" % c, + "subscribers_url": "https://api.github.com/repos/%(url)s/" + "subscribers" % c, + "subscription_url": "https://api.github.com/repos/%(url)s/" + "subscription" % c, + "commits_url": "https://api.github.com/repos/%(url)s/" "commits{/sha}" % c, + "git_commits_url": "https://api.github.com/repos/%(url)s/git/" + "commits{/sha}" % c, + "comments_url": "https://api.github.com/repos/%(url)s/" + "comments{/number}" % c, + "issue_comment_url": "https://api.github.com/repos/%(url)s/" + "issues/comments/{number}" % c, + "contents_url": "https://api.github.com/repos/%(url)s/" + "contents/{+path}" % c, + "compare_url": "https://api.github.com/repos/%(url)s/" + "compare/{base}...{head}" % c, + "merges_url": "https://api.github.com/repos/%(url)s/merges" % c, + "archive_url": "https://api.github.com/repos/%(url)s/" + "{archive_format}{/ref}" % c, + "downloads_url": "https://api.github.com/repos/%(url)s/" "downloads" % c, + "issues_url": "https://api.github.com/repos/%(url)s/" "issues{/number}" % c, + "pulls_url": "https://api.github.com/repos/%(url)s/" "pulls{/number}" % c, + "milestones_url": "https://api.github.com/repos/%(url)s/" + "milestones{/number}" % c, + "notifications_url": "https://api.github.com/repos/%(url)s/" + "notifications{?since,all,participating}" % c, + "labels_url": "https://api.github.com/repos/%(url)s/" "labels{/name}" % c, + "releases_url": "https://api.github.com/repos/%(url)s/" "releases{/id}" % c, + "created_at": "2014-02-26T07:39:11Z", + "updated_at": "2014-02-28T13:55:32Z", + "pushed_at": "2014-02-28T13:55:32Z", + "git_url": "git://github.com/%(url)s.git" % c, + "ssh_url": "git@github.com:%(url)s.git" % c, + "clone_url": "https://github.com/%(url)s.git" % c, + "svn_url": "https://github.com/%(url)s" % c, + "homepage": None, + "size": 388, + "stargazers_count": 0, + "watchers_count": 0, + "language": "Python", + "has_issues": False, + "has_downloads": True, + "has_wiki": True, + "forks_count": 0, + "mirror_url": None, + "open_issues_count": 0, + "forks": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": default_branch, + "master_branch": default_branch, + }, + "sender": { + "login": "%(user)s" % c, + "id": 1698163, + "avatar_url": "https://avatars.githubusercontent.com/u/1234578", + "gravatar_id": "12345678", + "url": "https://api.github.com/users/%(user)s" % c, + "html_url": "https://github.com/%(user)s" % c, + "followers_url": "https://api.github.com/users/%(user)s/" "followers" % c, + "following_url": "https://api.github.com/users/%(user)s/" + "following{/other_user}" % c, + "gists_url": "https://api.github.com/users/%(user)s/" "gists{/gist_id}" % c, + "starred_url": "https://api.github.com/users/%(user)s/" + "starred{/owner}{/repo}" % c, + "subscriptions_url": "https://api.github.com/users/%(user)s/" + "subscriptions" % c, + "organizations_url": "https://api.github.com/users/%(user)s/" "orgs" % c, + "repos_url": "https://api.github.com/users/%(user)s/repos" % c, + "events_url": "https://api.github.com/users/%(user)s/" + "events{/privacy}" % c, + "received_events_url": "https://api.github.com/users/%(user)s/" + "received_events" % c, + "type": "User", + "site_admin": False, + }, + } + + +def github_organization_metadata(login): + """Github organization fixture generator.""" + return { + "login": login, + "id": 1234, + "url": "https://api.github.com/orgs/%s" % login, + "repos_url": "https://api.github.com/orgs/%s/repos" % login, + "events_url": "https://api.github.com/orgs/%s/events" % login, + "members_url": "https://api.github.com/orgs/%s/" "members{/member}" % login, + "public_members_url": "https://api.github.com/orgs/%s/" + "public_members{/member}" % login, + "avatar_url": "https://avatars.githubusercontent.com/u/1234?", + } + + +def github_collaborator_metadata(admin: bool, login: str, id: int): + """Generate metadata for a repo collaborator.""" + return { + "login": login, + "id": id, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/%s" % login, + "html_url": "https://github.com/%s" % login, + "followers_url": "https://api.github.com/users/%s/followers" % login, + "following_url": "https://api.github.com/users/%s/following{/other_user}" + % login, + "gists_url": "https://api.github.com/users/%s/gists{/gist_id}" % login, + "starred_url": "https://api.github.com/users/%s/starred{/owner}{/repo}" % login, + "subscriptions_url": "https://api.github.com/users/%s/subscriptions" % login, + "organizations_url": "https://api.github.com/users/%s/orgs" % login, + "repos_url": "https://api.github.com/users/%s/repos" % login, + "events_url": "https://api.github.com/users/%s/events{/privacy}" % login, + "received_events_url": "https://api.github.com/users/%s/received_events" + % login, + "type": "User", + "site_admin": False, + "permissions": { + "pull": True, + "triage": True, + "push": True, + "maintain": True, + "admin": admin, + }, + "role_name": "write", + } + + +def github_contributor_metadata(id: int, login: str, contributions: int): + """Generate metadata for a repo contributor.""" + return { + "login": login, + "id": id, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/%s" % login, + "html_url": "https://github.com/%s" % login, + "followers_url": "https://api.github.com/users/%s/followers" % login, + "following_url": "https://api.github.com/users/%s/following{/other_user}" + % login, + "gists_url": "https://api.github.com/users/%s/gists{/gist_id}" % login, + "starred_url": "https://api.github.com/users/%s/starred{/owner}{/repo}" % login, + "subscriptions_url": "https://api.github.com/users/%s/subscriptions" % login, + "organizations_url": "https://api.github.com/users/%s/orgs" % login, + "repos_url": "https://api.github.com/users/%s/repos" % login, + "events_url": "https://api.github.com/users/%s/events{/privacy}" % login, + "received_events_url": "https://api.github.com/users/%s/received_events" + % login, + "type": "User", + "site_admin": False, + "contributions": contributions, + } + + +def github_webhook_metadata(id: int, url: str, repo_name: str): + """Generate metadata for a repo webhook.""" + return { + "type": "Repository", + "id": id, + "name": "web", + "active": True, + "events": ["push", "pull_request"], + "config": { + "content_type": "json", + "insecure_ssl": "0", + "url": url, + }, + "updated_at": "2019-06-03T00:57:16Z", + "created_at": "2019-06-03T00:57:16Z", + "url": "https://api.github.com/repos/%s/hooks/%d" % (repo_name, id), + "test_url": "https://api.github.com/repos/%s/hooks/%d/test" % (repo_name, id), + "ping_url": "https://api.github.com/repos/%s/hooks/%d/pings" % (repo_name, id), + "deliveries_url": "https://api.github.com/repos/%s/hooks/%d/deliveries" + % (repo_name, id), + "last_response": {"code": None, "status": "unused", "message": None}, + } + + +def github_release_metadata( + id: int, + repo_name: str, + tag_name: str, + release_name: str | None, + release_description: str | None, +): + """Generate metadata for a release.""" + return { + "url": "https://api.github.com/repos/%s/releases/%d" % (repo_name, id), + "html_url": "https://github.com/%s/releases/%s" % (repo_name, tag_name), + "assets_url": "https://api.github.com/repos/%s/releases/%d/assets" + % (repo_name, id), + "upload_url": "https://uploads.github.com/repos/%s/releases/%d/assets{?name,label}" + % (repo_name, id), + "tarball_url": "https://api.github.com/repos/%s/tarball/%s" + % (repo_name, tag_name), + "zipball_url": "https://api.github.com/repos/%s/zipball/%s" + % (repo_name, tag_name), + "id": id, + "node_id": "MDc6UmVsZWFzZTE=", + "tag_name": tag_name, + "target_commitish": "master", + "name": release_name, + "body": release_description, + "draft": False, + "prerelease": False, + "immutable": True, + "created_at": "2013-02-27T19:35:32Z", + "published_at": "2013-02-27T19:35:32Z", + "author": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": False, + }, + "assets": [ + { + "url": "https://api.github.com/repos/%s/releases/assets/1" % repo_name, + "browser_download_url": "https://github.com/%s/releases/download/%s/example.zip" + % (repo_name, tag_name), + "id": 1, + "node_id": "MDEyOlJlbGVhc2VBc3NldDE=", + "name": "example.zip", + "label": "short description", + "state": "uploaded", + "content_type": "application/zip", + "size": 1024, + "digest": "sha256:2151b604e3429bff440b9fbc03eb3617bc2603cda96c95b9bb05277f9ddba255", + "download_count": 42, + "created_at": "2013-02-27T19:35:32Z", + "updated_at": "2013-02-27T19:35:32Z", + "uploader": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": False, + }, + } + ], + } + + +def github_webhook_payload( + id: int, + tag_name: str, + release_name: str | None, + release_description: str | None, + repo_id: int, + repo_name: str, + repo_owner_id: int, + repo_owner_username: str, + repo_default_branch: str, +): + """Generate sample payload for a release event webhook.""" + return { + "action": "created", + "release": github_release_metadata( + id, repo_name, tag_name, release_name, release_description + ), + "repository": github_repo_metadata( + repo_owner_username, repo_owner_id, repo_name, repo_id, repo_default_branch + ), + } + + +class GitHubPatcher(TestProviderPatcher): + """Patch the GitHub API primitives to avoid real API calls and return test data instead.""" + + @staticmethod + def provider_factory() -> RepositoryServiceProviderFactory: + """GitHub provider factory.""" + return GitHubProviderFactory( + base_url="https://github.com", + webhook_receiver_url="http://localhost:5000/api/receivers/github/events/?access_token={token}", + ) + + @staticmethod + def test_webhook_payload( + generic_repository: GenericRepository, + generic_release: GenericRelease, + generic_repo_owner: GenericOwner, + ) -> dict[str, Any]: + """Return a sample webhook payload.""" + return github_webhook_payload( + int(generic_release.id), + generic_release.tag_name, + generic_release.name, + generic_release.body, + int(generic_repository.id), + generic_repository.full_name, + int(generic_repo_owner.id), + generic_repo_owner.path_name, + generic_repository.default_branch, + ) + + def patch( + self, + test_generic_repositories: list[GenericRepository], + test_generic_contributors: list[GenericContributor], + test_collaborators: list[dict[str, Any]], + test_generic_webhooks: list[GenericWebhook], + test_generic_user: GenericUser, + test_file: dict[str, Any], + ) -> Iterator[RepositoryServiceProvider]: + """Configure the patch and yield within the patched context.""" + mock_api = MagicMock() + mock_api.session = MagicMock() + mock_api.me.return_value = github3.users.User( + github_user_metadata( + id=int(test_generic_user.id), + display_name=test_generic_user.display_name, + login=test_generic_user.username, + email="%s@inveniosoftware.org" % test_generic_user.username, + ), + mock_api.session, + ) + + contributors: list[github3.users.Contributor] = [] + for generic_contributor in test_generic_contributors: + contributor = github3.users.Contributor( + github_contributor_metadata( + int(generic_contributor.id), + generic_contributor.username, + generic_contributor.contributions_count or 0, + ), + mock_api.session, + ) + contributor.refresh = MagicMock( + return_value=github3.users.User( + github_user_metadata( + int(generic_contributor.id), + generic_contributor.display_name, + generic_contributor.username, + "%s@inveniosoftware.org" % generic_contributor.username, + ), + mock_api.session, + ) + ) + contributors.append(contributor) + + collaborators: list[github3.users.Collaborator] = [] + for collaborator in test_collaborators: + collaborators.append( + github3.users.Collaborator( + github_collaborator_metadata( + collaborator["admin"], + collaborator["username"], + int(collaborator["id"]), + ), + mock_api.session, + ) + ) + + repos: dict[int, github3.repos.Repository] = {} + for generic_repo in test_generic_repositories: + repo = github3.repos.ShortRepository( + github_repo_metadata( + "auser", + 1, + generic_repo.full_name, + int(generic_repo.id), + generic_repo.default_branch, + ), + mock_api.session, + ) + + hooks: list[github3.repos.hook.Hook] = [] + for hook in test_generic_webhooks: + if hook.id != generic_repo.id: + continue + + hooks.append( + github3.repos.hook.Hook( + github_webhook_metadata( + int(hook.id), hook.url, generic_repo.full_name + ), + mock_api.session, + ) + ) + + repo.hooks = MagicMock(return_value=hooks) + repo.file_contents = MagicMock(return_value=None) + # Mock hook creation to return the hook id '12345' + hook_instance = MagicMock() + hook_instance.id = 12345 + repo.create_hook = MagicMock(return_value=hook_instance) + repo.collaborators = MagicMock(return_value=collaborators) + repo.contributors = MagicMock(return_value=contributors) + + def mock_file_contents(path: str, ref: str): + if path == test_file["path"]: + # Mock github3.contents.Content with file data + return MagicMock(decoded=test_file["content"].encode("ascii")) + raise github3.exceptions.NotFoundError(MagicMock(status_code=404)) + + repo.file_contents = MagicMock(side_effect=mock_file_contents) + + repos[int(generic_repo.id)] = repo + + repos_by_name = {r.full_name: r for r in repos.values()} + mock_api.repositories.return_value = repos.values() + + def mock_repo_with_id(id): + return repos.get(id) + + def mock_repo_by_name(owner, name): + return repos_by_name.get("/".join((owner, name))) + + def mock_head_status_by_repo_url(url, **kwargs): + url_specific_refs_tags = ( + "https://github.com/auser/repo-2/zipball/refs/tags/v1.0-tag-and-branch" + ) + if url.endswith("v1.0-tag-and-branch") and url != url_specific_refs_tags: + return MagicMock( + status_code=300, + links={"alternate": {"url": url_specific_refs_tags}}, + ) + else: + return MagicMock(status_code=200, url=url) + + mock_api.repository_with_id.side_effect = mock_repo_with_id + mock_api.repository.side_effect = mock_repo_by_name + mock_api.markdown.side_effect = lambda x: x + mock_api.session.head.side_effect = mock_head_status_by_repo_url + mock_api.session.get.return_value = MagicMock(raw=github_zipball()) + + with patch("invenio_vcs.contrib.github.GitHubProvider._gh", new=mock_api): + yield self.provider diff --git a/tests/contrib_fixtures/gitlab.py b/tests/contrib_fixtures/gitlab.py new file mode 100644 index 00000000..b4282c06 --- /dev/null +++ b/tests/contrib_fixtures/gitlab.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +# +# Some of the code in this file was taken from https://codebase.helmholtz.cloud/rodare/invenio-gitlab +# and relicensed under MIT with permission from the authors. +"""Fixture test impl for GitLab.""" + +from typing import Any, Iterator +from unittest.mock import MagicMock, patch + +import gitlab.const +import gitlab.v4.objects + +from invenio_vcs.contrib.gitlab import GitLabProviderFactory +from invenio_vcs.generic_models import ( + GenericContributor, + GenericOwner, + GenericRelease, + GenericRepository, + GenericUser, + GenericWebhook, +) +from invenio_vcs.providers import ( + RepositoryServiceProvider, + RepositoryServiceProviderFactory, +) +from tests.contrib_fixtures.patcher import TestProviderPatcher + + +def gitlab_namespace_metadata(id: int): + """Namespace metadata generator.""" + return { + "id": id, + "name": "Diaspora", + "path": "diaspora", + "kind": "group", + "full_path": "diaspora", + "parent_id": None, + "avatar_url": None, + "web_url": "https://gitlab.example.com/diaspora", + } + + +def gitlab_project_metadata( + id: int, full_name: str, default_branch: str, description: str | None +): + """Project metadata generator.""" + return { + "id": id, + "description": description, + "name": "Diaspora Client", + "name_with_namespace": "Diaspora / Diaspora Client", + "path": "diaspora-client", + "path_with_namespace": full_name, + "created_at": "2013-09-30T13:46:02Z", + "default_branch": default_branch, + "tag_list": ["example", "disapora client"], + "topics": ["example", "disapora client"], + "ssh_url_to_repo": "git@gitlab.example.com:%s.git" % full_name, + "http_url_to_repo": "https://gitlab.example.com/%s.git" % full_name, + "web_url": "https://gitlab.example.com/%s" % full_name, + "avatar_url": "https://gitlab.example.com/uploads/project/avatar/%d/uploads/avatar.png" + % id, + "star_count": 0, + "last_activity_at": "2013-09-30T13:46:02Z", + "visibility": "public", + "namespace": gitlab_namespace_metadata(1), + } + + +def gitlab_contributor_metadata( + email: str, contribution_count: int | None, name: str | None = "Example" +): + """Contributor metadata generator.""" + return { + "name": name, + "email": email, + "commits": contribution_count, + "additions": 0, + "deletions": 0, + } + + +def gitlab_user_metadata(id: int, username: str, name: str | None): + """User metadata generator.""" + return { + "id": id, + "username": username, + "name": name, + "state": "active", + "locked": False, + "avatar_url": "https://gitlab.example.com/uploads/user/avatar/%d/cd8.jpeg" % id, + "web_url": "https://gitlab.example.com/%s" % username, + } + + +def gitlab_webhook_metadata( + id: int, + project_id: int, + url: str, +): + """Webhook metadata generator.""" + return { + "id": id, + "url": url, + "name": "Hook name", + "description": "Hook description", + "project_id": project_id, + "push_events": True, + "push_events_branch_filter": "", + "issues_events": True, + "confidential_issues_events": True, + "merge_requests_events": True, + "tag_push_events": True, + "note_events": True, + "confidential_note_events": True, + "job_events": True, + "pipeline_events": True, + "wiki_page_events": True, + "deployment_events": True, + "releases_events": True, + "milestone_events": True, + "feature_flag_events": True, + "enable_ssl_verification": True, + "repository_update_events": True, + "alert_status": "executable", + "disabled_until": None, + "url_variables": [], + "created_at": "2012-10-12T17:04:47Z", + "resource_access_token_events": True, + "custom_webhook_template": '{"event":"{{object_kind}}"}', + "custom_headers": [{"key": "Authorization"}], + } + + +def gitlab_project_member_metadata(id: int, username: str, access_level: int): + """Project member metadata generator.""" + return { + "id": id, + "username": username, + "name": "Raymond Smith", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", + "web_url": "http://192.168.1.8:3000/root", + "created_at": "2012-09-22T14:13:35Z", + "created_by": { + "id": 2, + "username": "john_doe", + "name": "John Doe", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", + "web_url": "http://192.168.1.8:3000/root", + }, + "expires_at": "2012-10-22", + "access_level": access_level, + "group_saml_identity": None, + } + + +def gitlab_webhook_payload( + id: int, + tag_name: str, + release_name: str | None, + release_description: str | None, + project_id: int, + project_full_name: str, + project_default_branch: str, + project_description: str | None, +): + """Return a sample webhook payload.""" + return { + "id": id, + "created_at": "2020-11-02 12:55:12 UTC", + "description": release_description, + "name": release_name, + "released_at": "2020-11-02 12:55:12 UTC", + "tag": tag_name, + "object_kind": "release", + "project": gitlab_project_metadata( + project_id, project_full_name, project_default_branch, project_description + ), + "url": "https://example.com/gitlab-org/release-webhook-example/-/releases/v1.1", + "action": "create", + "assets": { + "count": 5, + "links": [ + { + "id": 1, + "link_type": "other", + "name": "Changelog", + "url": "https://example.net/changelog", + } + ], + "sources": [ + { + "format": "zip", + "url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.zip", + }, + { + "format": "tar.gz", + "url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar.gz", + }, + { + "format": "tar.bz2", + "url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar.bz2", + }, + { + "format": "tar", + "url": "https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar", + }, + ], + }, + "commit": { + "id": "ee0a3fb31ac16e11b9dbb596ad16d4af654d08f8", + "message": "Release v1.1", + "title": "Release v1.1", + "timestamp": "2020-10-31T14:58:32+11:00", + "url": "https://example.com/gitlab-org/release-webhook-example/-/commit/ee0a3fb31ac16e11b9dbb596ad16d4af654d08f8", + "author": {"name": "Example User", "email": "user@example.com"}, + }, + } + + +class GitLabPatcher(TestProviderPatcher): + """Patch the GitLab API primitives to avoid real API calls and return test data instead.""" + + @staticmethod + def provider_factory() -> RepositoryServiceProviderFactory: + """GitLab provider factory.""" + return GitLabProviderFactory( + base_url="https://gitlab.com", + webhook_receiver_url="http://localhost:5000/api/receivers/github/events/?access_token={token}", + ) + + @staticmethod + def test_webhook_payload( + generic_repository: GenericRepository, + generic_release: GenericRelease, + generic_repo_owner: GenericOwner, + ) -> dict[str, Any]: + """Return a sample webhook payload.""" + return gitlab_webhook_payload( + int(generic_release.id), + generic_release.tag_name, + generic_release.name, + generic_release.body, + int(generic_repository.id), + generic_repository.full_name, + generic_repository.default_branch, + generic_repository.description, + ) + + def patch( + self, + test_generic_repositories: list[GenericRepository], + test_generic_contributors: list[GenericContributor], + test_collaborators: list[dict[str, Any]], + test_generic_webhooks: list[GenericWebhook], + test_generic_user: GenericUser, + test_file: dict[str, Any], + ) -> Iterator[RepositoryServiceProvider]: + """Configure the patch and yield within the patched context.""" + mock_gl = MagicMock() + mock_gl.projects = MagicMock() + mock_gl.users = MagicMock() + mock_gl.namespaces = MagicMock() + + # We need contributors to correspond to users for the search operation. + # But the list should also contain the main test user. + test_user_email = "%s@inveniosoftware.org" % test_generic_user.username + test_user = gitlab.v4.objects.User( + mock_gl.users, + gitlab_user_metadata( + int(test_generic_user.id), + test_generic_user.username, + test_generic_user.display_name, + ), + ) + # The email isn't returned in the API response (see https://docs.gitlab.com/api/users/#as-a-regular-user) + # so we store it separately here for querying. + users: dict[str, gitlab.v4.objects.User] = {test_user_email: test_user} + mock_gl.user = test_user + + project_members: list[gitlab.v4.objects.ProjectMemberAll] = [] + for collaborator in test_collaborators: + project_members.append( + gitlab.v4.objects.ProjectMemberAll( + mock_gl.projects, + gitlab_project_member_metadata( + int(collaborator["id"]), + collaborator["username"], + ( + gitlab.const.MAINTAINER_ACCESS + if collaborator["admin"] + else gitlab.const.GUEST_ACCESS + ), + ), + ) + ) + + # Some lesser-used API routes return dicts instead of dedicated objects + contributors: list[dict[str, Any]] = [] + for generic_contributor in test_generic_contributors: + contributor_email = "%s@inveniosoftware.org" % generic_contributor.username + contributors.append( + gitlab_contributor_metadata( + contributor_email, + generic_contributor.contributions_count, + generic_contributor.display_name, + ) + ) + users[contributor_email] = gitlab.v4.objects.User( + mock_gl.users, + gitlab_user_metadata( + int(generic_contributor.id), + generic_contributor.username, + generic_contributor.display_name, + ), + ) + + def mock_users_list(search: str | None = None): + if search is None: + return users + return [users[search]] + + mock_gl.users.list = MagicMock(side_effect=mock_users_list) + + # We need to globally override this property because the method is provided as a + # property within a mixin which cannot be overriden on the instance level. + Project = gitlab.v4.objects.Project + Project.repository_contributors = MagicMock(return_value=contributors) + + projs: dict[int, gitlab.v4.objects.Project] = {} + for generic_repo in test_generic_repositories: + proj = Project( + mock_gl.projects, + gitlab_project_metadata( + int(generic_repo.id), + generic_repo.full_name, + generic_repo.default_branch, + generic_repo.description, + ), + ) + + hooks: list[gitlab.v4.objects.ProjectHook] = [] + for hook in test_generic_webhooks: + if hook.id != generic_repo.id: + continue + + hooks.append( + gitlab.v4.objects.ProjectHook( + mock_gl.projects, + gitlab_webhook_metadata( + int(hook.id), int(generic_repo.id), hook.url + ), + ) + ) + + proj.hooks = MagicMock() + proj.hooks.list = MagicMock(return_value=hooks) + new_hook = MagicMock() + new_hook.id = 12345 + proj.hooks.create = MagicMock(return_value=new_hook) + proj.hooks.delete = MagicMock() + + proj.members_all = MagicMock() + proj.members_all.list = MagicMock(return_value=project_members) + + def mock_get_file(file_path: str, ref: str): + if file_path == test_file["path"]: + file = MagicMock() + file.decode = MagicMock( + return_value=test_file["content"].encode("ascii") + ) + return file + else: + raise gitlab.GitlabGetError() + + proj.files = MagicMock() + proj.files.get = MagicMock(side_effect=mock_get_file) + + projs[int(generic_repo.id)] = proj + + def mock_projects_get(id: int, lazy=False): + """We need to take the lazy param even though we ignore it.""" + return projs[id] + + mock_gl.projects.list = MagicMock(return_value=projs.values()) + mock_gl.projects.get = MagicMock(side_effect=mock_projects_get) + + with patch("invenio_vcs.contrib.gitlab.GitLabProvider._gl", new=mock_gl): + yield self.provider diff --git a/tests/contrib_fixtures/patcher.py b/tests/contrib_fixtures/patcher.py new file mode 100644 index 00000000..bcada365 --- /dev/null +++ b/tests/contrib_fixtures/patcher.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. +"""Abstract provider-specific patcher class.""" + +from abc import ABC, abstractmethod +from typing import Any, Iterator + +from invenio_vcs.generic_models import ( + GenericContributor, + GenericOwner, + GenericRelease, + GenericRepository, + GenericUser, + GenericWebhook, +) +from invenio_vcs.providers import ( + RepositoryServiceProvider, + RepositoryServiceProviderFactory, +) + + +class TestProviderPatcher(ABC): + """Interface for specifying a provider-specific primitive API patch and other test helpers.""" + + def __init__(self, test_user) -> None: + """Constructor.""" + self.provider = self.provider_factory().for_user(test_user.id) + + @staticmethod + @abstractmethod + def provider_factory() -> RepositoryServiceProviderFactory: + """Return the factory for the provider.""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def test_webhook_payload( + generic_repository: GenericRepository, + generic_release: GenericRelease, + generic_repo_owner: GenericOwner, + ) -> dict[str, Any]: + """Return an example webhook payload.""" + raise NotImplementedError + + @abstractmethod + def patch( + self, + test_generic_repositories: list[GenericRepository], + test_generic_contributors: list[GenericContributor], + test_collaborators: list[dict[str, Any]], + test_generic_webhooks: list[GenericWebhook], + test_generic_user: GenericUser, + test_file: dict[str, Any], + ) -> Iterator[RepositoryServiceProvider]: + """Implement the patch. + + This should be applied to the primitives of the provider's API and not to e.g. the provider methods + themselves, as that would eliminate the purpose of testing the provider functionality. + + At the end, this should yield within the patch context to ensure the patch is applied throughout the + test case run and then unapplied at the end for consistency. + """ + raise NotImplementedError diff --git a/tests/fixtures.py b/tests/fixtures.py index ce4020a7..ff41d7dc 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,46 +1,25 @@ # -*- coding: utf-8 -*- # -# This file is part of Invenio. -# Copyright (C) 2023 CERN. +# Copyright (C) 2023-2025 CERN. # -# Invenio is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Invenio is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Invenio. If not, see . -# -# In applying this licence, CERN does not waive the privileges and immunities -# granted to it by virtue of its status as an Intergovernmental Organization -# or submit itself to any jurisdiction. - +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. """Define fixtures for tests.""" -import os -from base64 import b64encode -from zipfile import ZipFile - -from six import BytesIO -from invenio_github.api import GitHubRelease -from invenio_github.models import ReleaseStatus +from invenio_vcs.models import ReleaseStatus +from invenio_vcs.service import VCSRelease -class TestGithubRelease(GitHubRelease): - """Implements GithubRelease with test methods.""" +class TestVCSRelease(VCSRelease): + """Implements VCSRelease with test methods.""" def publish(self): """Sets release status to published. Does not create a "real" record, as this only used to test the API. """ - self.release_object.status = ReleaseStatus.PUBLISHED - self.release_object.record_id = "445aaacd-9de1-41ab-af52-25ab6cb93df7" + self.db_release.status = ReleaseStatus.PUBLISHED + self.db_release.record_id = "445aaacd-9de1-41ab-af52-25ab6cb93df7" return {} def process_release(self): @@ -55,439 +34,12 @@ def resolve_record(self): """ return {} + @property + def badge_title(self): + """Test title for the badge.""" + return "DOI" -# -# Fixture generators -# -def github_user_metadata(login, email=None, bio=True): - """Github user fixture generator.""" - username = login - - user = { - "avatar_url": "https://avatars.githubusercontent.com/u/7533764?", - "collaborators": 0, - "created_at": "2014-05-09T12:26:44Z", - "disk_usage": 0, - "events_url": "https://api.github.com/users/%s/events{/privacy}" % username, - "followers": 0, - "followers_url": "https://api.github.com/users/%s/followers" % username, - "following": 0, - "following_url": "https://api.github.com/users/%s/" - "following{/other_user}" % username, - "gists_url": "https://api.github.com/users/%s/gists{/gist_id}" % username, - "gravatar_id": "12345678", - "html_url": "https://github.com/%s" % username, - "id": 1234, - "login": "%s" % username, - "organizations_url": "https://api.github.com/users/%s/orgs" % username, - "owned_private_repos": 0, - "plan": { - "collaborators": 0, - "name": "free", - "private_repos": 0, - "space": 307200, - }, - "private_gists": 0, - "public_gists": 0, - "public_repos": 0, - "received_events_url": "https://api.github.com/users/%s/" - "received_events" % username, - "repos_url": "https://api.github.com/users/%s/repos" % username, - "site_admin": False, - "starred_url": "https://api.github.com/users/%s/" - "starred{/owner}{/repo}" % username, - "subscriptions_url": "https://api.github.com/users/%s/" - "subscriptions" % username, - "total_private_repos": 0, - "type": "User", - "updated_at": "2014-05-09T12:26:44Z", - "url": "https://api.github.com/users/%s" % username, - "hireable": False, - "location": "Geneve", - } - - if bio: - user.update( - { - "bio": "Software Engineer at CERN", - "blog": "http://www.cern.ch", - "company": "CERN", - "name": "Lars Holm Nielsen", - } - ) - - if email is not None: - user.update( - { - "email": email, - } - ) - - return user - - -def github_repo_metadata(owner, repo, repo_id): - """Github repository fixture generator.""" - repo_url = "%s/%s" % (owner, repo) - - return { - "archive_url": "https://api.github.com/repos/%s/" - "{archive_format}{/ref}" % repo_url, - "assignees_url": "https://api.github.com/repos/%s/" - "assignees{/user}" % repo_url, - "blobs_url": "https://api.github.com/repos/%s/git/blobs{/sha}" % repo_url, - "branches_url": "https://api.github.com/repos/%s/" - "branches{/branch}" % repo_url, - "clone_url": "https://github.com/%s.git" % repo_url, - "collaborators_url": "https://api.github.com/repos/%s/" - "collaborators{/collaborator}" % repo_url, - "comments_url": "https://api.github.com/repos/%s/" - "comments{/number}" % repo_url, - "commits_url": "https://api.github.com/repos/%s/commits{/sha}" % repo_url, - "compare_url": "https://api.github.com/repos/%s/compare/" - "{base}...{head}" % repo_url, - "contents_url": "https://api.github.com/repos/%s/contents/{+path}" % repo_url, - "contributors_url": "https://api.github.com/repos/%s/contributors" % repo_url, - "created_at": "2012-10-29T10:24:02Z", - "default_branch": "master", - "description": "", - "downloads_url": "https://api.github.com/repos/%s/downloads" % repo_url, - "events_url": "https://api.github.com/repos/%s/events" % repo_url, - "fork": False, - "forks": 0, - "forks_count": 0, - "forks_url": "https://api.github.com/repos/%s/forks" % repo_url, - "full_name": repo_url, - "git_commits_url": "https://api.github.com/repos/%s/git/" - "commits{/sha}" % repo_url, - "git_refs_url": "https://api.github.com/repos/%s/git/refs{/sha}" % repo_url, - "git_tags_url": "https://api.github.com/repos/%s/git/tags{/sha}" % repo_url, - "git_url": "git://github.com/%s.git" % repo_url, - "has_downloads": True, - "has_issues": True, - "has_wiki": True, - "homepage": None, - "hooks_url": "https://api.github.com/repos/%s/hooks" % repo_url, - "html_url": "https://github.com/%s" % repo_url, - "id": repo_id, - "issue_comment_url": "https://api.github.com/repos/%s/issues/" - "comments/{number}" % repo_url, - "issue_events_url": "https://api.github.com/repos/%s/issues/" - "events{/number}" % repo_url, - "issues_url": "https://api.github.com/repos/%s/issues{/number}" % repo_url, - "keys_url": "https://api.github.com/repos/%s/keys{/key_id}" % repo_url, - "labels_url": "https://api.github.com/repos/%s/labels{/name}" % repo_url, - "language": None, - "languages_url": "https://api.github.com/repos/%s/languages" % repo_url, - "merges_url": "https://api.github.com/repos/%s/merges" % repo_url, - "milestones_url": "https://api.github.com/repos/%s/" - "milestones{/number}" % repo_url, - "mirror_url": None, - "name": "altantis-conf", - "notifications_url": "https://api.github.com/repos/%s/" - "notifications{?since,all,participating}", - "open_issues": 0, - "open_issues_count": 0, - "owner": { - "avatar_url": "https://avatars.githubusercontent.com/u/1234?", - "events_url": "https://api.github.com/users/%s/" "events{/privacy}" % owner, - "followers_url": "https://api.github.com/users/%s/followers" % owner, - "following_url": "https://api.github.com/users/%s/" - "following{/other_user}" % owner, - "gists_url": "https://api.github.com/users/%s/gists{/gist_id}" % owner, - "gravatar_id": "1234", - "html_url": "https://github.com/%s" % owner, - "id": 1698163, - "login": "%s" % owner, - "organizations_url": "https://api.github.com/users/%s/orgs" % owner, - "received_events_url": "https://api.github.com/users/%s/" - "received_events" % owner, - "repos_url": "https://api.github.com/users/%s/repos" % owner, - "site_admin": False, - "starred_url": "https://api.github.com/users/%s/" - "starred{/owner}{/repo}" % owner, - "subscriptions_url": "https://api.github.com/users/%s/" - "subscriptions" % owner, - "type": "User", - "url": "https://api.github.com/users/%s" % owner, - }, - "permissions": {"admin": True, "pull": True, "push": True}, - "private": False, - "pulls_url": "https://api.github.com/repos/%s/pulls{/number}" % repo_url, - "pushed_at": "2012-10-29T10:28:08Z", - "releases_url": "https://api.github.com/repos/%s/releases{/id}" % repo_url, - "size": 104, - "ssh_url": "git@github.com:%s.git" % repo_url, - "stargazers_count": 0, - "stargazers_url": "https://api.github.com/repos/%s/stargazers" % repo_url, - "statuses_url": "https://api.github.com/repos/%s/statuses/{sha}" % repo_url, - "subscribers_url": "https://api.github.com/repos/%s/subscribers" % repo_url, - "subscription_url": "https://api.github.com/repos/%s/subscription" % repo_url, - "svn_url": "https://github.com/%s" % repo_url, - "tags_url": "https://api.github.com/repos/%s/tags" % repo_url, - "teams_url": "https://api.github.com/repos/%s/teams" % repo_url, - "trees_url": "https://api.github.com/repos/%s/git/trees{/sha}" % repo_url, - "updated_at": "2013-10-25T11:30:04Z", - "url": "https://api.github.com/repos/%s" % repo_url, - "watchers": 0, - "watchers_count": 0, - "deployments_url": "https://api.github.com/repos/%s/deployments" % repo_url, - "archived": False, - "has_pages": False, - "has_projects": False, - "network_count": 0, - "subscribers_count": 0, - } - - -def ZIPBALL(): - """Github repository ZIP fixture.""" - memfile = BytesIO() - zipfile = ZipFile(memfile, "w") - zipfile.writestr("test.txt", "hello world") - zipfile.close() - memfile.seek(0) - return memfile - - -def PAYLOAD(sender, repo, repo_id, tag="v1.0"): - """Github payload fixture generator.""" - c = dict(repo=repo, user=sender, url="%s/%s" % (sender, repo), id="4321", tag=tag) - - return { - "action": "published", - "release": { - "url": "https://api.github.com/repos/%(url)s/releases/%(id)s" % c, - "assets_url": "https://api.github.com/repos/%(url)s/releases/" - "%(id)s/assets" % c, - "upload_url": "https://uploads.github.com/repos/%(url)s/" - "releases/%(id)s/assets{?name}" % c, - "html_url": "https://github.com/%(url)s/releases/tag/%(tag)s" % c, - "id": int(c["id"]), - "tag_name": c["tag"], - "target_commitish": "master", - "name": "Release name", - "body": "", - "draft": False, - "author": { - "login": "%(user)s" % c, - "id": 1698163, - "avatar_url": "https://avatars.githubusercontent.com/u/12345", - "gravatar_id": "12345678", - "url": "https://api.github.com/users/%(user)s" % c, - "html_url": "https://github.com/%(user)s" % c, - "followers_url": "https://api.github.com/users/%(user)s/" - "followers" % c, - "following_url": "https://api.github.com/users/%(user)s/" - "following{/other_user}" % c, - "gists_url": "https://api.github.com/users/%(user)s/" - "gists{/gist_id}" % c, - "starred_url": "https://api.github.com/users/%(user)s/" - "starred{/owner}{/repo}" % c, - "subscriptions_url": "https://api.github.com/users/%(user)s/" - "subscriptions" % c, - "organizations_url": "https://api.github.com/users/%(user)s/" - "orgs" % c, - "repos_url": "https://api.github.com/users/%(user)s/repos" % c, - "events_url": "https://api.github.com/users/%(user)s/" - "events{/privacy}" % c, - "received_events_url": "https://api.github.com/users/" - "%(user)s/received_events" % c, - "type": "User", - "site_admin": False, - }, - "prerelease": False, - "created_at": "2014-02-26T08:13:42Z", - "published_at": "2014-02-28T13:55:32Z", - "assets": [], - "tarball_url": "https://api.github.com/repos/%(url)s/" - "tarball/%(tag)s" % c, - "zipball_url": "https://api.github.com/repos/%(url)s/" - "zipball/%(tag)s" % c, - }, - "repository": { - "id": repo_id, - "name": repo, - "full_name": "%(url)s" % c, - "owner": { - "login": "%(user)s" % c, - "id": 1698163, - "avatar_url": "https://avatars.githubusercontent.com/u/" "1698163", - "gravatar_id": "bbc951080061fc48cae0279d27f3c015", - "url": "https://api.github.com/users/%(user)s" % c, - "html_url": "https://github.com/%(user)s" % c, - "followers_url": "https://api.github.com/users/%(user)s/" - "followers" % c, - "following_url": "https://api.github.com/users/%(user)s/" - "following{/other_user}" % c, - "gists_url": "https://api.github.com/users/%(user)s/" - "gists{/gist_id}" % c, - "starred_url": "https://api.github.com/users/%(user)s/" - "starred{/owner}{/repo}" % c, - "subscriptions_url": "https://api.github.com/users/%(user)s/" - "subscriptions" % c, - "organizations_url": "https://api.github.com/users/%(user)s/" - "orgs" % c, - "repos_url": "https://api.github.com/users/%(user)s/" "repos" % c, - "events_url": "https://api.github.com/users/%(user)s/" - "events{/privacy}" % c, - "received_events_url": "https://api.github.com/users/" - "%(user)s/received_events" % c, - "type": "User", - "site_admin": False, - }, - "private": False, - "html_url": "https://github.com/%(url)s" % c, - "description": "Repo description.", - "fork": True, - "url": "https://api.github.com/repos/%(url)s" % c, - "forks_url": "https://api.github.com/repos/%(url)s/forks" % c, - "keys_url": "https://api.github.com/repos/%(url)s/" "keys{/key_id}" % c, - "collaborators_url": "https://api.github.com/repos/%(url)s/" - "collaborators{/collaborator}" % c, - "teams_url": "https://api.github.com/repos/%(url)s/teams" % c, - "hooks_url": "https://api.github.com/repos/%(url)s/hooks" % c, - "issue_events_url": "https://api.github.com/repos/%(url)s/" - "issues/events{/number}" % c, - "events_url": "https://api.github.com/repos/%(url)s/events" % c, - "assignees_url": "https://api.github.com/repos/%(url)s/" - "assignees{/user}" % c, - "branches_url": "https://api.github.com/repos/%(url)s/" - "branches{/branch}" % c, - "tags_url": "https://api.github.com/repos/%(url)s/tags" % c, - "blobs_url": "https://api.github.com/repos/%(url)s/git/" "blobs{/sha}" % c, - "git_tags_url": "https://api.github.com/repos/%(url)s/git/" - "tags{/sha}" % c, - "git_refs_url": "https://api.github.com/repos/%(url)s/git/" - "refs{/sha}" % c, - "trees_url": "https://api.github.com/repos/%(url)s/git/" "trees{/sha}" % c, - "statuses_url": "https://api.github.com/repos/%(url)s/" - "statuses/{sha}" % c, - "languages_url": "https://api.github.com/repos/%(url)s/" "languages" % c, - "stargazers_url": "https://api.github.com/repos/%(url)s/" "stargazers" % c, - "contributors_url": "https://api.github.com/repos/%(url)s/" - "contributors" % c, - "subscribers_url": "https://api.github.com/repos/%(url)s/" - "subscribers" % c, - "subscription_url": "https://api.github.com/repos/%(url)s/" - "subscription" % c, - "commits_url": "https://api.github.com/repos/%(url)s/" "commits{/sha}" % c, - "git_commits_url": "https://api.github.com/repos/%(url)s/git/" - "commits{/sha}" % c, - "comments_url": "https://api.github.com/repos/%(url)s/" - "comments{/number}" % c, - "issue_comment_url": "https://api.github.com/repos/%(url)s/" - "issues/comments/{number}" % c, - "contents_url": "https://api.github.com/repos/%(url)s/" - "contents/{+path}" % c, - "compare_url": "https://api.github.com/repos/%(url)s/" - "compare/{base}...{head}" % c, - "merges_url": "https://api.github.com/repos/%(url)s/merges" % c, - "archive_url": "https://api.github.com/repos/%(url)s/" - "{archive_format}{/ref}" % c, - "downloads_url": "https://api.github.com/repos/%(url)s/" "downloads" % c, - "issues_url": "https://api.github.com/repos/%(url)s/" "issues{/number}" % c, - "pulls_url": "https://api.github.com/repos/%(url)s/" "pulls{/number}" % c, - "milestones_url": "https://api.github.com/repos/%(url)s/" - "milestones{/number}" % c, - "notifications_url": "https://api.github.com/repos/%(url)s/" - "notifications{?since,all,participating}" % c, - "labels_url": "https://api.github.com/repos/%(url)s/" "labels{/name}" % c, - "releases_url": "https://api.github.com/repos/%(url)s/" "releases{/id}" % c, - "created_at": "2014-02-26T07:39:11Z", - "updated_at": "2014-02-28T13:55:32Z", - "pushed_at": "2014-02-28T13:55:32Z", - "git_url": "git://github.com/%(url)s.git" % c, - "ssh_url": "git@github.com:%(url)s.git" % c, - "clone_url": "https://github.com/%(url)s.git" % c, - "svn_url": "https://github.com/%(url)s" % c, - "homepage": None, - "size": 388, - "stargazers_count": 0, - "watchers_count": 0, - "language": "Python", - "has_issues": False, - "has_downloads": True, - "has_wiki": True, - "forks_count": 0, - "mirror_url": None, - "open_issues_count": 0, - "forks": 0, - "open_issues": 0, - "watchers": 0, - "default_branch": "master", - "master_branch": "master", - }, - "sender": { - "login": "%(user)s" % c, - "id": 1698163, - "avatar_url": "https://avatars.githubusercontent.com/u/1234578", - "gravatar_id": "12345678", - "url": "https://api.github.com/users/%(user)s" % c, - "html_url": "https://github.com/%(user)s" % c, - "followers_url": "https://api.github.com/users/%(user)s/" "followers" % c, - "following_url": "https://api.github.com/users/%(user)s/" - "following{/other_user}" % c, - "gists_url": "https://api.github.com/users/%(user)s/" "gists{/gist_id}" % c, - "starred_url": "https://api.github.com/users/%(user)s/" - "starred{/owner}{/repo}" % c, - "subscriptions_url": "https://api.github.com/users/%(user)s/" - "subscriptions" % c, - "organizations_url": "https://api.github.com/users/%(user)s/" "orgs" % c, - "repos_url": "https://api.github.com/users/%(user)s/repos" % c, - "events_url": "https://api.github.com/users/%(user)s/" - "events{/privacy}" % c, - "received_events_url": "https://api.github.com/users/%(user)s/" - "received_events" % c, - "type": "User", - "site_admin": False, - }, - } - - -def ORG(login): - """Github organization fixture generator.""" - return { - "login": login, - "id": 1234, - "url": "https://api.github.com/orgs/%s" % login, - "repos_url": "https://api.github.com/orgs/%s/repos" % login, - "events_url": "https://api.github.com/orgs/%s/events" % login, - "members_url": "https://api.github.com/orgs/%s/" "members{/member}" % login, - "public_members_url": "https://api.github.com/orgs/%s/" - "public_members{/member}" % login, - "avatar_url": "https://avatars.githubusercontent.com/u/1234?", - } - - -def github_file_contents(owner, repo, file_path, ref, data): - """Github content fixture generator.""" - c = dict( - url="%s/%s" % (owner, repo), - owner=owner, - repo=repo, - file=file_path, - ref=ref, - ) - - return { - "_links": { - "git": "https://api.github.com/repos/%(url)s/git/blobs/" - "aaaffdfbead0b67bd6a5f5819c458a1215ecb0f6" % c, - "html": "https://github.com/%(url)s/blob/%(ref)s/%(file)s" % c, - "self": "https://api.github.com/repos/%(url)s/contents/" - "%(file)s?ref=%(ref)s" % c, - }, - "content": b64encode(data), - "encoding": "base64", - "git_url": "https://api.github.com/repos/%(url)s/git/blobs/" - "aaaffdfbead0b67bd6a5f5819c458a1215ecb0f6" % c, - "html_url": "https://github.com/%(url)s/blob/%(ref)s/%(file)s" % c, - "name": os.path.basename(file_path), - "path": file_path, - "sha": "aaaffdfbead0b67bd6a5f5819c458a1215ecb0f6", - "size": 1209, - "type": "file", - "url": "https://api.github.com/repos/%(url)s/contents/" - "%(file)s?ref=%(ref)s" % c, - } + @property + def badge_value(self): + """Test value for the badge.""" + return self.db_release.tag diff --git a/tests/test_alembic.py b/tests/test_alembic.py index 79f82902..3d5fa48f 100644 --- a/tests/test_alembic.py +++ b/tests/test_alembic.py @@ -3,9 +3,9 @@ # Copyright (C) 2023 CERN. # Copyright (C) 2024 Graz University of Technology. # -# Invenio-Github is free software; you can redistribute it and/or modify +# Invenio-vcs is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. -"""Test invenio-github alembic.""" +"""Test invenio-vcs alembic.""" import pytest from invenio_db.utils import alembic_test_context, drop_alembic_version_table @@ -23,8 +23,9 @@ def test_alembic(base_app, database): # Check that this package's SQLAlchemy models have been properly registered tables = [x for x in db.metadata.tables] - assert "github_repositories" in tables - assert "github_releases" in tables + assert "vcs_repositories" in tables + assert "vcs_releases" in tables + assert "vcs_repository_users" in tables # Check that Alembic agrees that there's no further tables to create. assert len(ext.alembic.compare_metadata()) == 0 diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 44389d3f..00000000 --- a/tests/test_api.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2023-2025 CERN. -# -# Invenio-Github is free software; you can redistribute it and/or modify -# it under the terms of the MIT License; see LICENSE file for more details. -"""Test invenio-github api.""" - -import json - -import pytest -from invenio_webhooks.models import Event - -from invenio_github.api import GitHubAPI, GitHubRelease -from invenio_github.models import Release, ReleaseStatus - -from .fixtures import PAYLOAD as github_payload_fixture - -# GithubAPI tests - - -def test_github_api_create_hook(app, test_user, github_api): - """Test hook creation.""" - api = GitHubAPI(test_user.id) - api.init_account() - repo_id = 1 - repo_name = "repo-1" - hook_created = api.create_hook(repo_id=repo_id, repo_name=repo_name) - assert hook_created - - -# GithubRelease api tests - - -def test_release_api(app, test_user, github_api): - api = GitHubAPI(test_user.id) - api.init_account() - repo_id = 2 - repo_name = "repo-2" - - # Create a repo hook - hook_created = api.create_hook(repo_id=repo_id, repo_name=repo_name) - assert hook_created - - headers = [("Content-Type", "application/json")] - - payload = github_payload_fixture("auser", repo_name, repo_id, tag="v1.0") - with app.test_request_context(headers=headers, data=json.dumps(payload)): - event = Event.create( - receiver_id="github", - user_id=test_user.id, - ) - release = Release( - release_id=payload["release"]["id"], - tag=event.payload["release"]["tag_name"], - repository_id=repo_id, - event=event, - status=ReleaseStatus.RECEIVED, - ) - # Idea is to test the public interface of GithubRelease - gh = GitHubRelease(release) - - # Validate that public methods raise NotImplementedError - with pytest.raises(NotImplementedError): - gh.process_release() - - with pytest.raises(NotImplementedError): - gh.publish() - - assert getattr(gh, "retrieve_remote_file") is not None - - # Validate that an invalid file returns None - invalid_remote_file_contents = gh.retrieve_remote_file("test") - - assert invalid_remote_file_contents is None - - # Validate that a valid file returns its data - valid_remote_file_contents = gh.retrieve_remote_file("test.py") - - assert valid_remote_file_contents is not None - assert valid_remote_file_contents.decoded["name"] == "test.py" - - -def test_release_branch_tag_conflict(app, test_user, github_api): - api = GitHubAPI(test_user.id) - api.init_account() - repo_id = 2 - repo_name = "repo-2" - - # Create a repo hook - hook_created = api.create_hook(repo_id=repo_id, repo_name=repo_name) - assert hook_created - - headers = [("Content-Type", "application/json")] - - payload = github_payload_fixture( - "auser", repo_name, repo_id, tag="v1.0-tag-and-branch" - ) - with app.test_request_context(headers=headers, data=json.dumps(payload)): - event = Event.create( - receiver_id="github", - user_id=test_user.id, - ) - release = Release( - release_id=payload["release"]["id"], - tag=event.payload["release"]["tag_name"], - repository_id=repo_id, - event=event, - status=ReleaseStatus.RECEIVED, - ) - # Idea is to test the public interface of GithubRelease - rel_api = GitHubRelease(release) - resolved_url = rel_api.resolve_zipball_url() - ref_tag_url = ( - "https://github.com/auser/repo-2/zipball/refs/tags/v1.0-tag-and-branch" - ) - assert resolved_url == ref_tag_url - # Check that the original zipball URL from the event payload is not the same - assert rel_api.release_zipball_url != ref_tag_url diff --git a/tests/test_badge.py b/tests/test_badge.py index ae91319b..76d42d94 100644 --- a/tests/test_badge.py +++ b/tests/test_badge.py @@ -1,43 +1,100 @@ # -*- coding: utf-8 -*- # -# This file is part of Invenio. -# Copyright (C) 2015, 2016 CERN. +# Copyright (C) 2023-2025 CERN. # -# Invenio is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Invenio is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Invenio. If not, see . -# -# In applying this licence, CERN does not waive the privileges and immunities -# granted to it by virtue of its status as an Intergovernmental Organization -# or submit itself to any jurisdiction. - +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. """Test cases for badge creation.""" from __future__ import absolute_import +from unittest.mock import patch + +import pytest from flask import url_for +from flask_login import login_user +from invenio_accounts.testutils import login_user_via_session +from invenio_webhooks.models import Event + +from invenio_vcs.generic_models import GenericRelease, GenericRepository +from invenio_vcs.models import Release, ReleaseStatus, Repository +from invenio_vcs.service import VCSService + + +@pytest.mark.skip(reason="Unit tests for UI routes are unimplemented.") +def test_badge_views( + app, + db, + client, + test_user, + test_generic_repositories: list[GenericRepository], + test_generic_release: GenericRelease, + vcs_service: VCSService, +): + """Test create_badge method.""" + vcs_service.sync(hooks=False) + generic_repo = test_generic_repositories[0] + db_repo = Repository.get( + provider=vcs_service.provider.factory.id, provider_id=generic_repo.id + ) + db_repo.enabled_by_user_id = test_user.id + db.session.add(db_repo) + + event = Event( + # Receiver ID is same as provider ID + receiver_id=vcs_service.provider.factory.id, + user_id=test_user.id, + payload={}, + ) + + db_release = Release( + provider=vcs_service.provider.factory.id, + provider_id=test_generic_release.id, + tag=test_generic_release.tag_name, + repository=db_repo, + event=event, + status=ReleaseStatus.PUBLISHED, + ) + db.session.add(db_release) + db.session.commit() + + login_user(test_user) + login_user_via_session(client, email=test_user.email) + + def mock_url_for(target: str, **kwargs): + """The badge route handler calls url_for referencing a module we don't have access to during the test run. + + Testing the functionality of that module is out of scope here. + """ + return "https://example.com" + + with patch("invenio_vcs.views.badge.url_for", mock_url_for): + badge_url = url_for( + "invenio_vcs_badge.index", + provider=vcs_service.provider.factory.id, + repo_provider_id=generic_repo.id, + ) + badge_resp = client.get(badge_url) + # Expect a redirect to the badge formatter + assert badge_resp.status_code == 302 + + class TestAbortException(Exception): + def __init__(self, code: int) -> None: + self.code = code + + # Test with non-existent provider id + with patch( + "invenio_vcs.views.badge.abort", + # This would crash with the actual abort function as it would try to render the 404 Jinja + # template which is not available during tests. + lambda code: (_ for _ in ()).throw(TestAbortException(code)), + ): + badge_url = url_for( + "invenio_vcs_badge.index", + provider=vcs_service.provider.factory.id, + repo_provider_id="42", + ) + with pytest.raises(TestAbortException) as e: + client.get(badge_url) -# TODO uncomment when migrated -# def test_badge_views(app, release_model): -# """Test create_badge method.""" -# with app.test_client() as client: -# badge_url = url_for( -# "invenio_github_badge.index", github_id=release_model.release_id -# ) -# badge_resp = client.get(badge_url) -# assert release_model.record["doi"] in badge_resp.location - -# with app.test_client() as client: -# # Test with non-existent github id -# badge_url = url_for("invenio_github_badge.index", github_id=42) -# badge_resp = client.get(badge_url) -# assert badge_resp.status_code == 404 + assert e.value.code == 404 diff --git a/tests/test_invenio_github.py b/tests/test_invenio_github.py deleted file mode 100644 index ceed134a..00000000 --- a/tests/test_invenio_github.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of Invenio. -# Copyright (C) 2023 CERN. -# -# Invenio is free software; you can redistribute it -# and/or modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of the -# License, or (at your option) any later version. -# -# Invenio is distributed in the hope that it will be -# useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Invenio; if not, write to the -# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, -# MA 02111-1307, USA. -# -# In applying this license, CERN does not -# waive the privileges and immunities granted to it by virtue of its status -# as an Intergovernmental Organization or submit itself to any jurisdiction. - - -"""Module tests.""" - -from flask import Flask - -from invenio_github import InvenioGitHub - - -def test_version(): - """Test version import.""" - from invenio_github import __version__ - - assert __version__ - - -def test_init(): - """Test extension initialization.""" - app = Flask("testapp") - ext = InvenioGitHub(app) - assert "invenio-github" in app.extensions - - app = Flask("testapp") - ext = InvenioGitHub() - assert "invenio-github" not in app.extensions - ext.init_app(app) - assert "invenio-github" in app.extensions diff --git a/tests/test_invenio_vcs.py b/tests/test_invenio_vcs.py new file mode 100644 index 00000000..af033139 --- /dev/null +++ b/tests/test_invenio_vcs.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023-2025 CERN. +# +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Module tests.""" + +from flask import Flask + +from invenio_vcs import InvenioVCS + + +def test_version(): + """Test version import.""" + from invenio_vcs import __version__ + + assert __version__ + + +def test_init(): + """Test extension initialization.""" + app = Flask("testapp") + ext = InvenioVCS(app) + assert "invenio-vcs" in app.extensions + + app = Flask("testapp") + ext = InvenioVCS() + assert "invenio-vcs" not in app.extensions + ext.init_app(app) + assert "invenio-vcs" in app.extensions diff --git a/tests/test_models.py b/tests/test_models.py index be63e40f..241b139f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,30 +1,19 @@ # -*- coding: utf-8 -*- # -# This file is part of Invenio. -# Copyright (C) 2023 CERN. +# Copyright (C) 2023-2025 CERN. # -# Invenio is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Invenio is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Invenio. If not, see . -# -# In applying this licence, CERN does not waive the privileges and immunities -# granted to it by virtue of its status as an Intergovernmental Organization -# or submit itself to any jurisdiction. - -"""Test cases for badge creation.""" +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Test cases for VCS models.""" -from invenio_github.models import Repository +from invenio_vcs.models import Repository def test_repository_unbound(app): - """Test create_badge method.""" - assert Repository(name="org/repo", github_id=1).latest_release() is None + """Test unbound repository.""" + assert ( + Repository( + full_name="org/repo", provider_id="1", provider="test" + ).latest_release() + is None + ) diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 00000000..ee3343c5 --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Test invenio-vcs provider layer.""" + +from invenio_vcs.generic_models import ( + GenericContributor, + GenericRepository, + GenericWebhook, +) +from invenio_vcs.providers import RepositoryServiceProvider +from invenio_vcs.service import VCSService + + +def test_vcs_provider_list_repositories( + vcs_provider: RepositoryServiceProvider, + test_generic_repositories: list[GenericRepository], +): + repos = vcs_provider.list_repositories() + assert repos is not None + assert len(repos) == len(test_generic_repositories) + assert isinstance(repos[test_generic_repositories[0].id], GenericRepository) + + +def test_vcs_provider_list_hooks( + vcs_provider: RepositoryServiceProvider, + test_generic_repositories: list[GenericRepository], + test_generic_webhooks: list[GenericWebhook], +): + repo_id = test_generic_repositories[0].id + test_hooks = list( + filter(lambda h: h.repository_id == repo_id, test_generic_webhooks) + ) + hooks = vcs_provider.list_repository_webhooks(repo_id) + assert hooks is not None + assert len(hooks) == len(test_hooks) + assert hooks[0].id == test_hooks[0].id + + +def test_vcs_provider_list_user_ids(vcs_provider: RepositoryServiceProvider): + # This should correspond to the IDs in `test_collaborators` at least roughly + user_ids = vcs_provider.list_repository_user_ids("1") + assert user_ids is not None + # Only one user should have admin privileges + assert len(user_ids) == 1 + assert user_ids[0] == "1" + + +def test_vcs_provider_get_repository(vcs_provider: RepositoryServiceProvider): + repo = vcs_provider.get_repository("1") + assert repo is not None + + +def test_vcs_provider_create_hook( + # For this test, we need to init accounts so we need to use the service + vcs_service: VCSService, +): + repo_id = "1" + hook_created = vcs_service.provider.create_webhook(repository_id=repo_id) + assert hook_created is not None + + +def test_vcs_provider_get_own_user(vcs_provider: RepositoryServiceProvider): + own_user = vcs_provider.get_own_user() + assert own_user is not None + assert own_user.id == "1" + + +def test_vcs_provider_list_repository_contributors( + vcs_provider: RepositoryServiceProvider, + test_generic_contributors: list[GenericContributor], + test_generic_repositories: list[GenericRepository], +): + contributors = vcs_provider.list_repository_contributors( + test_generic_repositories[0].id, 10 + ) + assert contributors is not None + assert len(contributors) == len(test_generic_contributors) + # The list order is arbitrary so we cannot validate that the IDs match up + + +def test_vcs_provider_get_repository_owner( + vcs_provider: RepositoryServiceProvider, + test_generic_repositories: list[GenericRepository], +): + owner = vcs_provider.get_repository_owner(test_generic_repositories[0].id) + assert owner is not None + # We don't store the owner id in the generic repository model + assert owner.id == "1" diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 00000000..6e7a956b --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023-2025 CERN. +# +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Test invenio-vcs service layer.""" + +import json + +import pytest +from invenio_webhooks.models import Event + +from invenio_vcs.generic_models import ( + GenericOwner, + GenericRelease, + GenericRepository, +) +from invenio_vcs.models import Release, ReleaseStatus +from invenio_vcs.service import VCSRelease, VCSService +from tests.contrib_fixtures.patcher import TestProviderPatcher + + +def test_vcs_service_user_repositories( + vcs_service: VCSService, + test_generic_repositories: list[GenericRepository], +): + vcs_service.sync() + + user_available_repositories = list(vcs_service.user_available_repositories) + assert len(user_available_repositories) == len(test_generic_repositories) + + repo_id = test_generic_repositories[0].id + assert user_available_repositories[0].provider_id == repo_id + + # We haven't enabled any repositories yet + user_enabled_repositories = list(vcs_service.user_enabled_repositories) + assert len(user_enabled_repositories) == 0 + + vcs_service.enable_repository(repo_id) + user_enabled_repositories = list(vcs_service.user_enabled_repositories) + assert len(user_enabled_repositories) == 1 + assert user_enabled_repositories[0].provider_id == repo_id + assert user_enabled_repositories[0].hook is not None + + vcs_service.disable_repository(repo_id) + user_enabled_repositories = list(vcs_service.user_enabled_repositories) + assert len(user_enabled_repositories) == 0 + + +def test_vcs_service_list_repos(vcs_service: VCSService): + vcs_service.sync() + repos = vcs_service.list_repositories() + assert len(repos) == 3 + + +def test_vcs_service_get_repo_default_branch( + vcs_service: VCSService, test_generic_repositories: list[GenericRepository] +): + vcs_service.sync() + default_branch = vcs_service.get_repo_default_branch( + test_generic_repositories[0].id + ) + assert default_branch == test_generic_repositories[0].default_branch + + +def test_vcs_service_get_last_sync_time(vcs_service: VCSService): + vcs_service.sync() + last_sync_time = vcs_service.get_last_sync_time() + assert last_sync_time is not None + + +def test_vcs_service_get_repository( + vcs_service: VCSService, test_generic_repositories: list[GenericRepository] +): + vcs_service.sync() + repository = vcs_service.get_repository(test_generic_repositories[0].id) + assert repository is not None + assert repository.provider_id == test_generic_repositories[0].id + + +def test_release_api( + app, + test_user, + test_generic_repositories: list[GenericRepository], + test_generic_release: GenericRelease, + test_generic_owner: GenericOwner, + provider_patcher: TestProviderPatcher, + vcs_service: VCSService, +): + repo = test_generic_repositories[0] + headers = [("Content-Type", "application/json")] + + payload = provider_patcher.test_webhook_payload( + repo, test_generic_release, test_generic_owner + ) + with app.test_request_context(headers=headers, data=json.dumps(payload)): + event = Event.create( + receiver_id=provider_patcher.provider_factory().id, + user_id=test_user.id, + ) + release = Release( + provider_id=test_generic_release.id, + tag=test_generic_release.tag_name, + repository_id=repo.id, + event=event, + status=ReleaseStatus.RECEIVED, + ) + + # Idea is to test the public interface of VCSRelease + r = VCSRelease(release, vcs_service.provider) + + # Validate that public methods raise NotImplementedError + with pytest.raises(NotImplementedError): + r.process_release() + + with pytest.raises(NotImplementedError): + r.publish() + + # Validate that an invalid file returns None + invalid_remote_file_contents = vcs_service.provider.retrieve_remote_file( + repo.id, release.tag, "test" + ) + + assert invalid_remote_file_contents is None + + # Validate that a valid file returns its data + valid_remote_file_contents = vcs_service.provider.retrieve_remote_file( + repo.id, release.tag, "test.py" + ) + + assert valid_remote_file_contents is not None + assert isinstance(valid_remote_file_contents, bytes) + + +""" + +def test_release_branch_tag_conflict(app, test_user, github_api): + api = GitHubAPI(test_user.id) + api.init_account() + repo_id = 2 + repo_name = "repo-2" + + # Create a repo hook + hook_created = api.create_hook(repo_id=repo_id, repo_name=repo_name) + assert hook_created + + headers = [("Content-Type", "application/json")] + + payload = github_payload_fixture( + "auser", repo_name, repo_id, tag="v1.0-tag-and-branch" + ) + with app.test_request_context(headers=headers, data=json.dumps(payload)): + event = Event.create( + receiver_id="github", + user_id=test_user.id, + ) + release = Release( + release_id=payload["release"]["id"], + tag=event.payload["release"]["tag_name"], + repository_id=repo_id, + event=event, + status=ReleaseStatus.RECEIVED, + ) + # Idea is to test the public interface of GithubRelease + rel_api = VCSRelease(release) + resolved_url = rel_api.resolve_zipball_url() + ref_tag_url = ( + "https://github.com/auser/repo-2/zipball/refs/tags/v1.0-tag-and-branch" + ) + assert resolved_url == ref_tag_url + # Check that the original zipball URL from the event payload is not the same + assert rel_api.release_zipball_url != ref_tag_url +""" diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 88ada337..15d857bf 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,89 +1,75 @@ # -*- coding: utf-8 -*- # -# This file is part of Invenio. -# Copyright (C) 2023 CERN. +# Copyright (C) 2025 CERN. # -# Invenio is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Invenio is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Invenio. If not, see . -# -# In applying this licence, CERN does not waive the privileges and immunities -# granted to it by virtue of its status as an Intergovernmental Organization -# or submit itself to any jurisdiction. +# Invenio-VCS is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Test celery task handlers.""" from time import sleep +from unittest.mock import patch from invenio_oauthclient.models import RemoteAccount from invenio_webhooks.models import Event -from mock import patch - -from invenio_github.api import GitHubAPI -from invenio_github.models import Release, ReleaseStatus, Repository -from invenio_github.tasks import process_release, refresh_accounts -from invenio_github.utils import iso_utcnow -from . import fixtures +from invenio_vcs.generic_models import ( + GenericOwner, + GenericRelease, + GenericRepository, +) +from invenio_vcs.models import Release, ReleaseStatus +from invenio_vcs.providers import RepositoryServiceProvider +from invenio_vcs.service import VCSService +from invenio_vcs.tasks import process_release, refresh_accounts +from invenio_vcs.utils import iso_utcnow +from tests.contrib_fixtures.patcher import TestProviderPatcher def test_real_process_release_task( - app, db, location, tester_id, remote_token, github_api + db, + tester_id, + vcs_service: VCSService, + test_generic_repositories: list[GenericRepository], + test_generic_release: GenericRelease, + test_generic_owner: GenericOwner, + provider_patcher: TestProviderPatcher, ): - # Initialise account - api = GitHubAPI(tester_id) - api.init_account() - api.sync() + vcs_service.sync() - # Get remote account extra data - extra_data = remote_token.remote_account.extra_data + generic_repo = test_generic_repositories[0] + vcs_service.enable_repository(generic_repo.id) + db_repo = vcs_service.get_repository(generic_repo.id) - assert 1 in extra_data["repos"] - assert "repo-1" in extra_data["repos"][1]["full_name"] - assert 2 in extra_data["repos"] - assert "repo-2" in extra_data["repos"][2]["full_name"] - - repo_name = "repo-1" - repo_id = 1 - - repo = Repository.create(tester_id, repo_id, repo_name) - api.enable_repo(repo, 12345) event = Event( - receiver_id="github", + # Receiver ID is same as provider ID + receiver_id=vcs_service.provider.factory.id, user_id=tester_id, - payload=fixtures.PAYLOAD("auser", "repo-1", 1), + payload=provider_patcher.test_webhook_payload( + generic_repo, test_generic_release, test_generic_owner + ), ) - release_object = Release( - release_id=event.payload["release"]["id"], - tag=event.payload["release"]["tag_name"], - repository=repo, + db_release = Release( + provider=vcs_service.provider.factory.id, + provider_id=test_generic_release.id, + tag=test_generic_release.tag_name, + repository=db_repo, event=event, status=ReleaseStatus.RECEIVED, ) - db.session.add(release_object) + db.session.add(db_release) db.session.commit() - process_release.delay(release_object.release_id) - assert repo.releases.count() == 1 - release = repo.releases.first() + process_release.delay(vcs_service.provider.factory.id, db_release.provider_id) + assert db_repo.releases.count() == 1 + release = db_repo.releases.first() assert release.status == ReleaseStatus.PUBLISHED - # This uuid is a fake one set by TestGithubRelease fixture + # This uuid is a fake one set by TestVCSRelease fixture assert str(release.record_id) == "445aaacd-9de1-41ab-af52-25ab6cb93df7" -def test_refresh_accounts(app, db, tester_id, remote_token, github_api): - """Test account refresh task.""" - +def test_refresh_accounts(db, test_user, vcs_provider: RepositoryServiceProvider): def mocked_sync(hooks=True, async_hooks=True): - """Mock sync function and update the remote account.""" account = RemoteAccount.query.all()[0] account.extra_data.update( dict( @@ -92,15 +78,15 @@ def mocked_sync(hooks=True, async_hooks=True): ) db.session.commit() - with patch("invenio_github.api.GitHubAPI.sync", side_effect=mocked_sync): + with patch("invenio_vcs.service.VCSService.sync", side_effect=mocked_sync): updated = RemoteAccount.query.all()[0].updated expiration_threshold = {"seconds": 1} sleep(2) - refresh_accounts.delay(expiration_threshold) + refresh_accounts.delay(vcs_provider.factory.id, expiration_threshold) last_update = RemoteAccount.query.all()[0].updated assert updated != last_update - refresh_accounts.delay(expiration_threshold) + refresh_accounts.delay(vcs_provider.factory.id, expiration_threshold) assert last_update == RemoteAccount.query.all()[0].updated diff --git a/tests/test_views.py b/tests/test_views.py index 8e447218..5ea770e1 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,28 +2,37 @@ # # Copyright (C) 2023 CERN. # -# Invenio-Github is free software; you can redistribute it and/or modify +# Invenio-VCS is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. -"""Test invenio-github views.""" +"""Test invenio-vcs views.""" + +from flask import url_for from flask_security import login_user from invenio_accounts.testutils import login_user_via_session -from invenio_oauthclient.models import RemoteAccount + +from invenio_vcs.generic_models import GenericRepository +from invenio_vcs.service import VCSService -def test_api_init_user(app, client, github_api, test_user): +def test_api_sync( + app, + client, + test_user, + vcs_service: VCSService, + test_generic_repositories: list[GenericRepository], +): # Login the user login_user(test_user) login_user_via_session(client, email=test_user.email) - # Initialise user account - res = client.post("/user/github", follow_redirects=True) + assert len(list(vcs_service.user_available_repositories)) == 0 + res = client.post( + url_for( + "invenio_vcs_api.sync_user_repositories", + provider=vcs_service.provider.factory.id, + ) + ) assert res.status_code == 200 - - # Validate RemoteAccount exists between querying it - remote_accounts = RemoteAccount.query.filter_by(user_id=test_user.id).all() - assert len(remote_accounts) == 1 - remote_account = remote_accounts[0] - - # Account init adds user's github data to its remote account extra data - assert remote_account.extra_data - assert len(remote_account.extra_data.keys()) + assert len(list(vcs_service.user_available_repositories)) == len( + test_generic_repositories + ) diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 14c3317e..2938c326 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,58 +1,73 @@ # -*- coding: utf-8 -*- -# # This file is part of Invenio. -# Copyright (C) 2023 CERN. -# -# Invenio is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Invenio is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. +# Copyright (C) 2025 CERN. # -# You should have received a copy of the GNU General Public License -# along with Invenio. If not, see . -# -# In applying this licence, CERN does not waive the privileges and immunities -# granted to it by virtue of its status as an Intergovernmental Organization -# or submit itself to any jurisdiction. +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. -"""Test GitHub hook.""" +"""Test vcs hook.""" import json # from invenio_rdm_records.proxies import current_rdm_records_service from invenio_webhooks.models import Event -from invenio_github.api import GitHubAPI -from invenio_github.models import ReleaseStatus, Repository - - -def test_webhook_post(app, db, tester_id, remote_token, github_api): - """Test webhook POST success.""" - from . import fixtures - - repo_id = 3 - repo_name = "arepo" - hook = 1234 - tag = "v1.0" - - repo = Repository.get(github_id=repo_id, name=repo_name) - if not repo: - repo = Repository.create(tester_id, repo_id, repo_name) +from invenio_vcs.generic_models import ( + GenericOwner, + GenericOwnerType, + GenericRelease, + GenericRepository, + GenericWebhook, +) +from invenio_vcs.models import ReleaseStatus, Repository +from invenio_vcs.utils import utcnow +from tests.contrib_fixtures.patcher import TestProviderPatcher + + +def test_webhook_post( + app, + db, + tester_id, + test_generic_repositories: list[GenericRepository], + test_generic_webhooks: list[GenericWebhook], + test_generic_release: GenericRelease, + test_generic_owner: GenericOwner, + provider_patcher: TestProviderPatcher, +): + generic_repo = test_generic_repositories[0] + generic_webhook = next( + h for h in test_generic_webhooks if h.repository_id == generic_repo.id + ) - api = GitHubAPI(tester_id) + db_repo = Repository.get( + provider=provider_patcher.provider_factory().id, provider_id=generic_repo.id + ) + if not db_repo: + db_repo = Repository.create( + provider=provider_patcher.provider_factory().id, + provider_id=generic_repo.id, + default_branch=generic_repo.default_branch, + full_name=generic_repo.full_name, + description=generic_repo.description, + license_spdx=generic_repo.license_spdx, + ) # Enable repository webhook. - api.enable_repo(repo, hook) - - payload = json.dumps(fixtures.PAYLOAD("auser", repo_name, repo_id, tag)) + db_repo.hook = generic_webhook.id + db_repo.enabled_by_user_id = tester_id + db.session.add(db_repo) + db.session.commit() + + payload = json.dumps( + provider_patcher.test_webhook_payload( + generic_repo, test_generic_release, test_generic_owner + ) + ) headers = [("Content-Type", "application/json")] with app.test_request_context(headers=headers, data=payload): - event = Event.create(receiver_id="github", user_id=tester_id) + event = Event.create( + receiver_id=provider_patcher.provider_factory().id, user_id=tester_id + ) # Add event to session. Otherwise defaults are not added (e.g. response and response_code) db.session.add(event) db.session.commit() @@ -60,47 +75,68 @@ def test_webhook_post(app, db, tester_id, remote_token, github_api): assert event.response_code == 202 # Validate that a release was created - assert repo.releases.count() == 1 - release = repo.releases.first() + assert db_repo.releases.count() == 1 + release = db_repo.releases.first() assert release.status == ReleaseStatus.PUBLISHED - assert release.release_id == event.payload["release"]["id"] - assert release.tag == tag - # This uuid is a fake one set by TestGithubRelease fixture + assert release.provider_id == test_generic_release.id + assert release.tag == test_generic_release.tag_name + # This uuid is a fake one set by TestVCSRelease fixture assert str(release.record_id) == "445aaacd-9de1-41ab-af52-25ab6cb93df7" assert release.errors is None -def test_webhook_post_fail(app, tester_id, remote_token, github_api): - """Test webhook POST failure.""" - from . import fixtures - - repo_id = 3 - repo_name = "arepo" - hook = 1234 - - # Create a repository - repo = Repository.get(github_id=repo_id, name=repo_name) - if not repo: - repo = Repository.create(tester_id, repo_id, repo_name) +def test_webhook_post_fail( + app, + tester_id, + test_generic_repositories: list[GenericRepository], + test_generic_webhooks: list[GenericWebhook], + provider_patcher: TestProviderPatcher, +): + generic_repo = test_generic_repositories[0] + generic_webhook = next( + h for h in test_generic_webhooks if h.repository_id == generic_repo.id + ) - api = GitHubAPI(tester_id) + db_repo = Repository.get( + provider=provider_patcher.provider_factory().id, provider_id=generic_repo.id + ) + if not db_repo: + db_repo = Repository.create( + provider=provider_patcher.provider_factory().id, + provider_id=generic_repo.id, + default_branch=generic_repo.default_branch, + full_name=generic_repo.full_name, + description=generic_repo.description, + license_spdx=generic_repo.license_spdx, + ) # Enable repository webhook. - api.enable_repo(repo, hook) + db_repo.hook = generic_webhook.id + db_repo.enabled_by_user_id = tester_id # Create an invalid payload (fake repo) fake_payload = json.dumps( - fixtures.PAYLOAD("fake_user", "fake_repo", 1000, "v1000.0") + provider_patcher.test_webhook_payload( + GenericRepository( + id="123", + full_name="fake_repo", + default_branch="fake_branch", + ), + GenericRelease( + id="123", + tag_name="v123.345", + created_at=utcnow(), + ), + GenericOwner(id="123", path_name="fake_user", type=GenericOwnerType.Person), + ) ) headers = [("Content-Type", "application/json")] with app.test_request_context(headers=headers, data=fake_payload): # user_id = request.oauth.access_token.user_id - event = Event.create(receiver_id="github", user_id=tester_id) + event = Event.create( + receiver_id=provider_patcher.provider_factory().id, user_id=tester_id + ) event.process() # Repo does not exist assert event.response_code == 404 - - # Create an invalid payload (fake user) - # TODO 'fake_user' does not match the invenio user 'extra_data'. Should this fail? - # TODO what should happen if an event is received and the account is not synced?