From 29d7e134dfe353e62e993b2a0bd68c6b471c9a7d Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 7 Sep 2026 12:32:43 +0530 Subject: [PATCH] test: assert what a provider refuses instead of skipping A declared capability dropped the test that needed it. Where the adapter documents a refusal, the shared test now asserts it, and where the provider answers differently the test asserts that answer. Four behaviours the sharing surfaced: - Gitea says 'synchronized' for a pushed head. Consumers only act on 'synchronize', so a pull request update was read as an unknown action. - GitLab raised a bare Exception for a repository that does not exist, and answered the old path of a deleted one through a redirect. - GitHub asked for a user with no credential, the only call in the adapter that did. - getInstallationRepository() was stubbed three times over; the refusal belongs on Git, next to the other four. GitLab's test class keeps no test of its own, and Bitbucket only what only Bitbucket does. Co-Authored-By: Claude Opus 5 --- docs/add-new-vcs-adapter.md | 8 +- src/VCS/Adapter/Git.php | 13 + src/VCS/Adapter/Git/Bitbucket.php | 5 - src/VCS/Adapter/Git/GitHub.php | 16 +- src/VCS/Adapter/Git/GitLab.php | 19 +- src/VCS/Adapter/Git/Gitea.php | 22 +- src/VCS/Adapter/Git/Gogs.php | 14 +- tests/VCS/Adapter/BitbucketTest.php | 66 +++-- tests/VCS/Adapter/ForgejoTest.php | 6 +- tests/VCS/Adapter/GitHubTest.php | 37 ++- tests/VCS/Adapter/GitLabTest.php | 101 +++----- tests/VCS/Adapter/GiteaTest.php | 40 ++- tests/VCS/Adapter/GogsTest.php | 3 +- tests/VCS/Base.php | 381 +++++++++++++++++++--------- 14 files changed, 451 insertions(+), 280 deletions(-) diff --git a/docs/add-new-vcs-adapter.md b/docs/add-new-vcs-adapter.md index dfea5301..bac42a4a 100644 --- a/docs/add-new-vcs-adapter.md +++ b/docs/add-new-vcs-adapter.md @@ -95,7 +95,13 @@ rather than `getEvent()` — the latter reports only the first event of a batch. ### Testing with Docker 🛠️ -The existing test suite is helpful when developing a new VCS adapter. Use official Docker images from trusted sources. Add new tests for your new VCS adapter in `tests/VCS/Adapter/VCSTest.php` test class. The specific `docker-compose` command for testing can be found in the [README](/README.md#tests). +Every adapter runs the same suite. `tests/VCS/Base.php` holds the tests, and each adapter's class under `tests/VCS/Adapter/` declares how its provider differs. To test a new adapter: + +1. Extend `Utopia\Tests\Base` in `tests/VCS/Adapter/NewGitAdapterTest.php` and implement its hooks: `setupAdapter()` builds the adapter against the provider, `signWebhookPayload()` signs a payload the way the provider does, and `pushPayload()` and `pullRequestPayload()` build webhook payloads shaped the way the provider sends them. +2. Declare the parts of the contract the provider lacks by overriding the capability flags, such as `$supportsTags` or `$supportsCheckRuns`. The first shared test for each capability then asserts that the adapter refuses with `X() is not supported by `; the tests that need the capability to act on skip. +3. Keep behaviour only this provider has in the adapter's own test class. Anything two providers share belongs in `Base`, behind a declared flag or hook. + +Run the provider from an official Docker image, add a Docker Compose profile and a PHPUnit test suite for it, and run the suite as described in [CONTRIBUTING](/CONTRIBUTING.md#running-tests). ### Tips and Tricks 💡 diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index f8816925..d2009907 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -126,6 +126,19 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, throw new Exception('getRepositoryPresignedUrl() is not supported by ' . $this->getName()); } + /** + * Get a repository the installation reaches, by name. + * + * Only GitHub models installations, so the default reports it as + * unsupported. + * + * @return array + */ + public function getInstallationRepository(string $repositoryName): array + { + throw new Exception('getInstallationRepository() is not supported by ' . $this->getName()); + } + /** * Create a check run for a commit. * diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 95f2eff8..70c5749f 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -335,11 +335,6 @@ public function hasAccessToAllRepositories(): bool return true; } - public function getInstallationRepository(string $repositoryName): array - { - throw new Exception("getInstallationRepository is not applicable for this adapter"); - } - public function searchRepositories(string $owner, int $page, int $per_page, string $search = ''): array { $url = "/repositories/{$owner}?page={$page}&pagelen={$per_page}"; diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 826b92ee..75677318 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -122,7 +122,7 @@ public function createRepository(string $owner, string $repositoryName, bool $pr */ public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array { - throw new Exception('Not implemented'); + throw new Exception('createPullRequest() is not supported by ' . $this->getName()); } /** @@ -698,9 +698,15 @@ protected function generateAccessToken(string $privateKey, ?string $appId): void */ public function getUser(string $username): array { - $response = $this->call(self::METHOD_GET, '/users/' . $username); + $response = $this->call(self::METHOD_GET, '/users/' . rawurlencode($username), ['Authorization' => "Bearer $this->accessToken"]); - return $response; + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get user: HTTP {$statusCode}", $statusCode); + } + + return $response['body'] ?? []; } /** @@ -1454,11 +1460,11 @@ public function validateWebhookEvent(string $payload, string $signature, string public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array { - throw new Exception('createTag() is not implemented for GitHub'); + throw new Exception('createTag() is not supported by ' . $this->getName()); } public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array { - throw new Exception('getCommitStatuses() is not implemented for GitHub'); + throw new Exception('getCommitStatuses() is not supported by ' . $this->getName()); } } diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 6d25871b..aeb665d1 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -181,11 +181,12 @@ public function getRepository(string $owner, string $repositoryName): array $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}"; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + // GitLab redirects the old path of a renamed or deleted project, which no longer names it + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [], true, false); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { + if ($responseHeadersStatusCode !== 200) { throw new RepositoryNotFound("Repository not found"); } @@ -236,11 +237,6 @@ public function hasAccessToAllRepositories(): bool return true; } - public function getInstallationRepository(string $repositoryName): array - { - throw new Exception("getInstallationRepository is not applicable for this adapter"); - } - /** * List namespaces the current user can browse: their personal namespace * plus every group they belong to. GitLab's own /namespaces endpoint @@ -353,8 +349,12 @@ public function getRepositoryName(string $repositoryId): string $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + throw new RepositoryNotFound("Repository {$repositoryId} not found"); + } + if ($responseHeadersStatusCode >= 400) { - throw new Exception("Repository {$repositoryId} not found"); + throw new Exception("Failed to get repository {$repositoryId}: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); } $responseBody = $response['body'] ?? []; @@ -688,6 +688,9 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode === 404) { + throw new RepositoryNotFound("Repository {$repositoryId} not found"); + } if ($statusCode >= 400) { throw new Exception("Failed to get owner name for repository {$repositoryId}: HTTP {$statusCode}", $statusCode); } diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index d5d7ab18..e36f091f 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -14,6 +14,12 @@ class Gitea extends Git public const CONTENTS_DIRECTORY = 'dir'; + /** + * Gitea says 'synchronized' for a pushed head where consumers expect + * 'synchronize'; every other action passes through as sent. + */ + private const PULL_REQUEST_ACTION_MAP = ['synchronized' => 'synchronize']; + protected string $endpoint = 'http://gitea:3000/api/v1'; protected string $accessToken; @@ -231,21 +237,6 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri ]; } - /** - * Get installation repository - * - * Note: Gitea doesn't have GitHub App installations. - * This method is not applicable and throws an exception. - * - * @param string $repositoryName Name of the repository - * @return array - * @throws Exception Always throws as installations don't exist in Gitea - */ - public function getInstallationRepository(string $repositoryName): array - { - throw new Exception("getInstallationRepository is not applicable for this adapter - use getRepository() with owner and repo name instead"); - } - public function getRepository(string $owner, string $repositoryName): array { $url = "/repos/{$owner}/{$repositoryName}"; @@ -1206,6 +1197,7 @@ public function getEvents(string $event, string $payload): array $branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . "/src/branch/" . $branch : ''; $pullRequestNumber = $payload['number'] ?? ''; $action = $payload['action'] ?? ''; + $action = self::PULL_REQUEST_ACTION_MAP[$action] ?? $action; $owner = $payloadRepositoryOwner['login'] ?? ''; $authorUrl = $payloadSender['html_url'] ?? ''; $authorAvatarUrl = $payloadPullRequestUser['avatar_url'] ?? ''; diff --git a/src/VCS/Adapter/Git/Gogs.php b/src/VCS/Adapter/Git/Gogs.php index e84de099..26deaecd 100644 --- a/src/VCS/Adapter/Git/Gogs.php +++ b/src/VCS/Adapter/Git/Gogs.php @@ -431,7 +431,7 @@ private function exec(string $command): string */ public function listRepositoryLanguages(string $owner, string $repositoryName): array { - throw new Exception("Listing repository languages is not supported by Gogs"); + throw new Exception('listRepositoryLanguages() is not supported by ' . $this->getName()); } /** @@ -474,7 +474,7 @@ public function createTag(string $owner, string $repositoryName, string $tagName */ public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array { - throw new Exception("Pull request API is not supported by Gogs"); + throw new Exception('createPullRequest() is not supported by ' . $this->getName()); } /** @@ -484,7 +484,7 @@ public function createPullRequest(string $owner, string $repositoryName, string */ public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array { - throw new Exception("Pull request API is not supported by Gogs"); + throw new Exception('getPullRequest() is not supported by ' . $this->getName()); } /** @@ -494,7 +494,7 @@ public function getPullRequest(string $owner, string $repositoryName, int $pullR */ public function getPullRequestFromBranch(string $owner, string $repositoryName, string $branch): array { - throw new Exception("Pull request API is not supported by Gogs"); + throw new Exception('getPullRequestFromBranch() is not supported by ' . $this->getName()); } /** @@ -504,7 +504,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, */ public function getPullRequestFiles(string $owner, string $repositoryName, int $pullRequestNumber): array { - throw new Exception("Pull request API is not supported by Gogs"); + throw new Exception('getPullRequestFiles() is not supported by ' . $this->getName()); } /** @@ -514,7 +514,7 @@ public function getPullRequestFiles(string $owner, string $repositoryName, int $ */ public function updateCommitStatus(string $repositoryName, string $commitHash, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void { - throw new Exception("Commit status API is not supported by Gogs"); + throw new Exception('updateCommitStatus() is not supported by ' . $this->getName()); } /** @@ -526,7 +526,7 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s */ public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array { - throw new Exception("Commit status API is not supported by Gogs"); + throw new Exception('getCommitStatuses() is not supported by ' . $this->getName()); } /** diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 3bb9cffb..afc3a082 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -8,7 +8,7 @@ use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git\Bitbucket; -class BitbucketTest extends Base +final class BitbucketTest extends Base { // Bitbucket routes by "workspace/slug" rather than a numeric id protected const EVENT_REPOSITORY_ID = self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; @@ -28,10 +28,26 @@ class BitbucketTest extends Base protected static string $pushEventName = 'repo:push'; protected static string $pullRequestEventName = 'pullrequest:created'; + /** + * Bitbucket names the action in the event rather than the payload, and + * has no reopen event. + * + * @var array + */ + protected static array $pullRequestActions = [ + 'pullrequest:created' => 'opened', + 'pullrequest:updated' => 'synchronize', + 'pullrequest:fulfilled' => 'closed', + 'pullrequest:rejected' => 'closed', + ]; + protected static bool $supportsInstallationRepository = false; - protected static bool $supportsRepositoryLanguages = false; protected static bool $reportsAffectedFilesInPushEvent = false; + // A repository reports the one language it was labelled with, not the + // languages of the files it holds + protected static bool $detectsRepositoryLanguages = false; + // Bitbucket has no repository to resolve an owner from; getOwnerName() // reports the account the token belongs to protected static bool $resolvesOwnerFromRepositoryId = false; @@ -40,7 +56,7 @@ class BitbucketTest extends Base protected static bool $supportsNamespaceListing = false; // Accounts are looked up by uuid, not by handle - protected static bool $supportsUserLookup = false; + protected static bool $resolvesUsersByHandle = false; // Bitbucket Cloud can't reach a local test catcher protected static bool $supportsWebhookDelivery = false; @@ -82,6 +98,7 @@ protected function setupAdapter(): void * * @param array $repository */ + #[\Override] protected function ownerOf(array $repository): string { $this->assertArrayHasKey('workspace', $repository); @@ -91,7 +108,13 @@ protected function ownerOf(array $repository): string return (string) $repository['workspace']['slug']; } - protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string + #[\Override] + protected function pullRequestEventFor(string $action): string + { + return $action; + } + + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false, array $olderCommits = []): string { $ref = [ 'type' => 'branch', @@ -104,6 +127,16 @@ protected function pushPayload(string $branch, array $added = [], array $removed ], ]; + // The adapter reads the head off new.target, so the commit list is + // there to prove the first commit listed is not taken for it + $commits = \array_map(fn (string $hash) => [ + 'hash' => $hash, + 'message' => 'Older commit', + 'author' => ['raw' => 'Older Author '], + 'links' => ['html' => ['href' => self::REPOSITORY_URL . '/commits/' . $hash]], + ], $olderCommits); + $commits[] = $ref['target']; + // A created branch has no old state and a deleted one no new state. The // file lists go unused, Bitbucket naming no files in a push. return (string) json_encode([ @@ -115,12 +148,16 @@ protected function pushPayload(string $branch, array $added = [], array $removed 'closed' => $deleted, 'old' => $created ? null : $ref, 'new' => $deleted ? null : $ref, + 'commits' => $commits, ]], ], ]); } - protected function pullRequestPayload(bool $external = false): string + /** + * The event names the action, so the payload is the same for every one. + */ + protected function pullRequestPayload(bool $external = false, string $action = 'pullrequest:created'): string { return (string) json_encode([ 'actor' => $this->eventActor(), @@ -224,23 +261,4 @@ public function testGetEventsReportsEveryPushedBranch(): void $this->assertSame([], $this->vcsAdapter->getEvents(static::$pushEventName, $tagsOnly)); } - - public function testGetEventPullRequestActionMapping(): void - { - $mapping = [ - 'pullrequest:created' => 'opened', - 'pullrequest:updated' => 'synchronize', - 'pullrequest:fulfilled' => 'closed', - 'pullrequest:rejected' => 'closed', - ]; - - foreach ($mapping as $event => $action) { - $events = $this->vcsAdapter->getEvents($event, $this->pullRequestPayload()); - $this->assertIsArray($events); - $this->assertCount(1, $events); - $result = $events[0]; - - $this->assertSame($action, $result['action'], "event '{$event}' should map to '{$action}'"); - } - } } diff --git a/tests/VCS/Adapter/ForgejoTest.php b/tests/VCS/Adapter/ForgejoTest.php index 5ee0436e..ba3fc5c8 100644 --- a/tests/VCS/Adapter/ForgejoTest.php +++ b/tests/VCS/Adapter/ForgejoTest.php @@ -7,7 +7,7 @@ use Utopia\System\System; use Utopia\VCS\Adapter\Git\Forgejo; -class ForgejoTest extends GiteaTest +final class ForgejoTest extends GiteaTest { protected static string $accessToken = ''; @@ -16,6 +16,10 @@ class ForgejoTest extends GiteaTest protected static string $eventHeader = 'x-forgejo-event'; protected static string $signatureHeader = 'x-forgejo-signature'; + // Forgejo's API user carries html_url, which Gitea 1.21's does not + protected static bool $reportsCommitAuthorUrl = true; + + #[\Override] protected function setupAdapter(): void { if (empty(static::$accessToken)) { diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index a31f5851..fe40938d 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -8,19 +8,22 @@ use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git\GitHub; -class GitHubTest extends Base +final class GitHubTest extends Base { protected static string $owner = ''; - protected static string $defaultBranch = 'main'; + protected static string $existingUser = ''; + protected static string $installationId = ''; /** @var array */ protected static array $supportedWebhookScopes = [GitHub::WEBHOOK_SCOPE_INSTALLATION, GitHub::WEBHOOK_SCOPE_REPOSITORY]; + protected static string $userHandleField = 'login'; + protected static string $eventHeader = 'x-github-event'; + protected static string $signatureHeader = 'x-hub-signature-256'; protected static string $avatarDomain = 'githubusercontent.com'; protected static bool $supportsPullRequestCreation = false; protected static bool $supportsNamespaceListing = false; protected static bool $supportsCommitStatusLookup = false; protected static bool $supportsTags = false; - protected static bool $supportsUserLookup = false; protected static bool $computesLanguagesAsynchronously = true; protected static bool $supportsWebhookDelivery = false; protected static bool $resolvesOwnerFromRepositoryId = false; @@ -30,8 +33,6 @@ protected function signWebhookPayload(string $payload, string $secret): string { return 'sha256=' . hash_hmac('sha256', $payload, $secret); } - protected static string $eventHeader = 'x-github-event'; - protected static string $signatureHeader = 'x-hub-signature-256'; protected function setupAdapter(): void { @@ -56,11 +57,23 @@ protected function setupAdapter(): void static::$owner = $adapter->getOwnerName(static::$installationId); } + // The account the app is installed on is the one user known to exist + static::$existingUser = static::$owner; + $this->vcsAdapter = $adapter; } - protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false, array $olderCommits = []): string { + $repositoryUrl = 'https://github.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; + + $olderEntries = \array_map(fn (string $hash) => [ + 'id' => $hash, + 'message' => 'Older commit', + 'url' => $repositoryUrl . '/commit/' . $hash, + 'author' => ['name' => 'Older Author', 'email' => 'older@example.com'], + ], $olderCommits); + return (string) json_encode([ 'created' => $created, 'deleted' => $deleted, @@ -72,17 +85,17 @@ protected function pushPayload(string $branch, array $added = [], array $removed 'name' => self::EVENT_REPOSITORY_NAME, 'full_name' => self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, 'private' => true, - 'html_url' => 'https://github.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME, + 'html_url' => $repositoryUrl, 'owner' => ['name' => self::EVENT_OWNER, 'login' => self::EVENT_OWNER], ], 'installation' => ['id' => 1234], 'head_commit' => [ 'id' => self::EVENT_COMMIT_HASH, 'message' => self::EVENT_COMMIT_MESSAGE, - 'url' => 'https://github.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME . '/commit/' . self::EVENT_COMMIT_HASH, + 'url' => $repositoryUrl . '/commit/' . self::EVENT_COMMIT_HASH, 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], ], - 'commits' => [[ + 'commits' => [...$olderEntries, [ 'id' => self::EVENT_COMMIT_HASH, 'added' => $added, 'removed' => $removed, @@ -95,12 +108,12 @@ protected function pushPayload(string $branch, array $added = [], array $removed ]); } - protected function pullRequestPayload(bool $external = false): string + protected function pullRequestPayload(bool $external = false, string $action = 'opened'): string { $headOwner = $external ? 'someone-else' : self::EVENT_OWNER; return (string) json_encode([ - 'action' => 'opened', + 'action' => $action, 'number' => self::EVENT_PULL_REQUEST_NUMBER, 'pull_request' => [ 'id' => 1303283688, @@ -188,7 +201,7 @@ public function testListBranchesPagination(): void $noMatch = $adapter->listBranches(static::$owner, $repositoryName, 100, 1, 'xyz'); $this->assertEmpty($noMatch); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } } diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 13c4704b..0757b8b3 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -8,24 +8,34 @@ use Utopia\Tests\Base; use Utopia\VCS\Adapter\Git\GitLab; -class GitLabTest extends Base +final class GitLabTest extends Base { protected static string $accessToken = ''; protected static string $owner = ''; - protected static string $defaultBranch = 'main'; protected static string $openPullRequestState = 'opened'; protected static string $eventHeader = 'x-gitlab-event'; protected static string $signatureHeader = 'x-gitlab-token'; protected static string $pushEventName = 'Push Hook'; protected static string $pullRequestEventName = 'Merge Request Hook'; + /** + * GitLab names merge request actions as verbs, and a merged one is closed. + * + * @var array + */ + protected static array $pullRequestActions = [ + 'open' => 'opened', + 'reopen' => 'reopened', + 'update' => 'synchronize', + 'close' => 'closed', + 'merge' => 'closed', + ]; + /** @var array */ protected static array $pullRequestOpenedActions = ['opened', 'synchronize']; protected static string $presignedTarballFragment = '/repository/archive.tar.gz?access_token='; protected static string $presignedZipballFragment = '/repository/archive.zip?access_token='; - protected static string $repositoryNotFoundException = \Exception::class; - protected static bool $deletesRepositoriesSynchronously = false; protected static bool $supportsCheckRuns = false; protected static bool $supportsInstallationRepository = false; protected static bool $reportsCommitAuthorAvatar = false; @@ -72,6 +82,7 @@ protected function setupAdapter(): void /** * GitLab owners are carried as "id:path", but it reports the path alone. */ + #[\Override] protected function ownerPath(): string { return \explode(':', static::$owner)[1] ?? static::$owner; @@ -82,6 +93,7 @@ protected function ownerPath(): string * * @param array $repository */ + #[\Override] protected function ownerOf(array $repository): string { $this->assertArrayHasKey('namespace', $repository); @@ -96,6 +108,7 @@ protected function ownerOf(array $repository): string * * @param array $repository */ + #[\Override] protected function isPrivate(array $repository): bool { $this->assertArrayHasKey('visibility', $repository); @@ -109,6 +122,7 @@ protected function isPrivate(array $repository): bool * * @param array $pullRequest */ + #[\Override] protected function pullRequestNumberOf(array $pullRequest): int { $this->assertArrayHasKey('iid', $pullRequest); @@ -129,74 +143,18 @@ protected function setupGitLab(): void } } - public function testGetEventPushMatchesCheckoutSha(): void - { - $payload = json_encode([ - 'object_kind' => 'push', - 'ref' => 'refs/heads/main', - 'checkout_sha' => 'def456', - 'project' => [ - 'name' => 'test-repo', - 'namespace' => 'test-org', - ], - 'commits' => [ - [ - 'id' => 'abc123', - 'message' => 'Older commit', - 'url' => 'http://example.com/commit/abc123', - 'author' => ['name' => 'Old Author'], - ], - [ - 'id' => 'def456', - 'message' => 'Head commit', - 'url' => 'http://example.com/commit/def456', - 'author' => ['name' => 'Head Author'], - ], - ], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $events = $this->vcsAdapter->getEvents('Push Hook', $payload); - $this->assertIsArray($events); - $this->assertCount(1, $events); - $result = $events[0]; - - $this->assertIsArray($result); - $this->assertSame('def456', $result['commitHash']); - $this->assertSame('Head Author', $result['headCommitAuthorName']); - $this->assertSame('Head commit', $result['headCommitMessage']); - $this->assertSame('http://example.com/commit/def456', $result['headCommitUrl']); - } - - public function testGetEventPullRequestActionMapping(): void - { - foreach (['open' => 'opened', 'reopen' => 'reopened', 'update' => 'synchronize', 'close' => 'closed', 'merge' => 'closed'] as $native => $mapped) { - $payload = json_encode([ - 'object_kind' => 'merge_request', - 'project' => ['id' => 1, 'name' => 'r', 'namespace' => 'o', 'web_url' => 'http://example.com/o/r'], - 'object_attributes' => ['iid' => 1, 'action' => $native, 'source_branch' => 'f', 'target_branch' => 'main'], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $events = $this->vcsAdapter->getEvents('Merge Request Hook', $payload); - $this->assertIsArray($events); - $this->assertCount(1, $events); - $result = $events[0]; - $this->assertSame($mapped, $result['action'], "native action '{$native}' should map to '{$mapped}'"); - } - } - - protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false, array $olderCommits = []): string { $blank = str_repeat('0', 40); $repositoryUrl = 'http://example.com/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; + $olderEntries = \array_map(fn (string $hash) => [ + 'id' => $hash, + 'message' => 'Older commit', + 'url' => $repositoryUrl . '/-/commit/' . $hash, + 'author' => ['name' => 'Older Author', 'email' => 'older@example.com'], + ], $olderCommits); + return (string) json_encode([ 'object_kind' => 'push', 'ref' => 'refs/heads/' . $branch, @@ -211,7 +169,7 @@ protected function pushPayload(string $branch, array $added = [], array $removed 'namespace' => self::EVENT_OWNER, 'web_url' => $repositoryUrl, ], - 'commits' => $deleted ? [] : [[ + 'commits' => $deleted ? [] : [...$olderEntries, [ 'id' => self::EVENT_COMMIT_HASH, 'message' => self::EVENT_COMMIT_MESSAGE, 'url' => $repositoryUrl . '/-/commit/' . self::EVENT_COMMIT_HASH, @@ -223,7 +181,7 @@ protected function pushPayload(string $branch, array $added = [], array $removed ]); } - protected function pullRequestPayload(bool $external = false): string + protected function pullRequestPayload(bool $external = false, string $action = 'open'): string { return (string) json_encode([ 'object_kind' => 'merge_request', @@ -236,8 +194,7 @@ protected function pullRequestPayload(bool $external = false): string 'object_attributes' => [ 'iid' => self::EVENT_PULL_REQUEST_NUMBER, 'title' => 'Test MR', - // GitLab calls it 'open' and normalizes to 'opened' - 'action' => 'open', + 'action' => $action, 'source_branch' => self::EVENT_HEAD_BRANCH, 'target_branch' => static::$defaultBranch, 'source_project_id' => $external ? 456 : (int) self::EVENT_REPOSITORY_ID, diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 91d735f1..69b5ad53 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -12,28 +12,39 @@ class GiteaTest extends Base { protected static string $accessToken = ''; protected static string $owner = ''; - protected static string $defaultBranch = 'main'; protected static string $existingUser = 'utopia'; protected static string $userHandleField = 'login'; protected static string $eventHeader = 'x-gitea-event'; protected static string $signatureHeader = 'x-gitea-signature'; + /** + * Gitea says 'synchronized' for a pushed head, which the adapter normalizes. + * + * @var array + */ + protected static array $pullRequestActions = [ + 'opened' => 'opened', + 'reopened' => 'reopened', + 'synchronized' => 'synchronize', + 'closed' => 'closed', + ]; + /** @var array */ - protected static array $pullRequestOpenedActions = ['opened', 'synchronized']; + protected static array $pullRequestOpenedActions = ['opened', 'synchronize']; protected static string $presignedTarballFragment = '.tar.gz?token='; protected static string $presignedZipballFragment = '.zip?token='; - - protected function signWebhookPayload(string $payload, string $secret): string - { - return hash_hmac('sha256', $payload, $secret); - } protected static string $avatarDomain = 'gravatar.com'; protected static bool $supportsCheckRuns = false; protected static bool $supportsNamespaceListing = false; protected static bool $supportsInstallationRepository = false; protected static bool $reportsCommitAuthorUrl = false; + protected function signWebhookPayload(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + protected function setupAdapter(): void { if (empty(static::$accessToken)) { @@ -71,10 +82,17 @@ protected function setupGitea(): void } } - protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false, array $olderCommits = []): string { $repositoryUrl = 'http://gitea:3000/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; + $olderEntries = \array_map(fn (string $hash) => [ + 'id' => $hash, + 'message' => 'Older commit', + 'url' => $repositoryUrl . '/commit/' . $hash, + 'author' => ['name' => 'Older Author', 'email' => 'older@example.com'], + ], $olderCommits); + return (string) json_encode([ 'ref' => 'refs/heads/' . $branch, 'before' => 'abc123', @@ -99,7 +117,7 @@ protected function pushPayload(string $branch, array $added = [], array $removed 'url' => $repositoryUrl . '/commit/' . self::EVENT_COMMIT_HASH, 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], ], - 'commits' => [[ + 'commits' => [...$olderEntries, [ 'id' => self::EVENT_COMMIT_HASH, 'added' => $added, 'removed' => $removed, @@ -108,7 +126,7 @@ protected function pushPayload(string $branch, array $added = [], array $removed ]); } - protected function pullRequestPayload(bool $external = false): string + protected function pullRequestPayload(bool $external = false, string $action = 'opened'): string { $repositoryUrl = 'http://gitea:3000/' . self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; $headRepository = $external @@ -116,7 +134,7 @@ protected function pullRequestPayload(bool $external = false): string : self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; return (string) json_encode([ - 'action' => 'opened', + 'action' => $action, 'number' => self::EVENT_PULL_REQUEST_NUMBER, 'pull_request' => [ 'id' => 1, diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index 10c5a7d1..9f4ac05b 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -7,7 +7,7 @@ use Utopia\System\System; use Utopia\VCS\Adapter\Git\Gogs; -class GogsTest extends GiteaTest +final class GogsTest extends GiteaTest { protected static string $accessToken = ''; protected static string $owner = ''; @@ -22,6 +22,7 @@ class GogsTest extends GiteaTest protected static string $eventHeader = 'x-gogs-event'; protected static string $signatureHeader = 'x-gogs-signature'; + #[\Override] protected function setupAdapter(): void { if (empty(static::$accessToken)) { diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index ab571542..00eb2c93 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -39,7 +39,9 @@ abstract class Base extends TestCase protected static string $defaultBranch = 'main'; /** - * Username of an account that exists on the instance under test. + * Handle of an account that exists on the instance under test. Where the + * provider resolves an owner from a repository, it is also the account the + * token belongs to, which getOwnerName() reports when given no repository. */ protected static string $existingUser = 'root'; @@ -74,10 +76,23 @@ abstract class Base extends TestCase protected static string $pullRequestEventName = 'pull_request'; + /** + * Actions the provider sends for a pull request, each with the shared + * vocabulary it normalizes to. GitHub's names are that vocabulary. + * + * @var array + */ + protected static array $pullRequestActions = [ + 'opened' => 'opened', + 'reopened' => 'reopened', + 'synchronize' => 'synchronize', + 'closed' => 'closed', + ]; + /** * Actions the provider may report for a newly opened pull request. Gitea - * follows the opened event with a synchronized one for the head it just - * pushed, and the catcher only keeps the last delivery. + * and GitLab may follow the opened event with a synchronize one for the + * head just pushed, and the catcher only keeps the last delivery. * * @var array */ @@ -99,15 +114,9 @@ abstract class Base extends TestCase protected static bool $supportsInstallationRepository = true; /** - * Exception the provider raises for a repository id that does not exist. - * - * @var class-string<\Throwable> - */ - protected static string $repositoryNotFoundException = RepositoryNotFound::class; - - /** - * Parts of the contract a provider may not offer at all. Each one skips the - * tests that need it, instead of every adapter overriding them to say so. + * Parts of the contract a provider may not offer at all. Where the adapter + * documents a refusal, the test asserts it; the rest skip what they cannot + * reach. */ protected static bool $supportsPullRequestCreation = true; @@ -119,10 +128,21 @@ abstract class Base extends TestCase protected static bool $supportsTags = true; - protected static bool $supportsUserLookup = true; + /** + * Whether getUser() takes a handle. Bitbucket looks accounts up by UUID or + * Atlassian account id, so a handle resolves nothing there. + */ + protected static bool $resolvesUsersByHandle = true; protected static bool $supportsRepositoryLanguages = true; + /** + * Whether the provider works out the languages a repository holds. Bitbucket + * reports the single language a repository was labelled with instead, so it + * answers the call without ever describing the files. + */ + protected static bool $detectsRepositoryLanguages = true; + protected static bool $supportsWebhookDelivery = true; protected static bool $resolvesOwnerFromRepositoryId = true; @@ -147,16 +167,11 @@ abstract class Base extends TestCase protected static bool $computesLanguagesAsynchronously = false; /** - * Host the provider serves commit author avatars from. + * Fragment of the URL the provider serves commit author avatars from: a + * host, or a path where the provider serves them itself. */ protected static string $avatarDomain = ''; - /** - * Whether a repository is gone as soon as delete returns. GitLab schedules - * it instead. - */ - protected static bool $deletesRepositoriesSynchronously = true; - /** * Whether a new repository starts with no commits. The Gogs adapter creates * one with an initial commit, so it never has an empty repository. @@ -165,7 +180,7 @@ abstract class Base extends TestCase /** * Whether the provider links the commit author back to an account. GitLab - * reports neither, Gitea an avatar but no profile url. + * reports neither; Gitea an avatar but no profile url, which Forgejo adds. */ protected static bool $reportsCommitAuthorAvatar = true; @@ -195,14 +210,25 @@ abstract protected function signWebhookPayload(string $payload, string $secret): * @param array $added * @param array $removed * @param array $modified + * @param array $olderCommits Hashes listed before the head commit, each with its own message and author */ - abstract protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string; + abstract protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false, array $olderCommits = []): string; /** * Build a pull request payload shaped the way this provider sends one, - * opening EVENT_HEAD_BRANCH against the default branch. + * opening EVENT_HEAD_BRANCH against the default branch. The action is the + * provider's own name for it, and defaults to its opened one. */ - abstract protected function pullRequestPayload(bool $external = false): string; + abstract protected function pullRequestPayload(bool $external = false, string $action = 'opened'): string; + + /** + * Event a pull request action is delivered under. Bitbucket names the + * action in the event rather than the payload. + */ + protected function pullRequestEventFor(string $action): string + { + return static::$pullRequestEventName; + } protected function setUp(): void { @@ -258,7 +284,7 @@ protected function ownerPath(): string } /** - * Owner of a repository, as GitHub and Gitea report it. GitLab overrides this. + * Owner of a repository, as GitHub and Gitea report it. GitLab and Bitbucket override this. * * @param array $repository */ @@ -319,10 +345,14 @@ protected function assertCommitAuthorLinks(array $commit): void { if (static::$reportsCommitAuthorAvatar) { $this->assertNotEmpty($commit['commitAuthorAvatar']); + } else { + $this->assertSame('', $commit['commitAuthorAvatar']); } if (static::$reportsCommitAuthorUrl) { $this->assertNotEmpty($commit['commitAuthorUrl']); + } else { + $this->assertSame('', $commit['commitAuthorUrl']); } } @@ -333,6 +363,20 @@ protected function skipUnlessSupported(bool $supported, string $capability): voi } } + /** + * A provider that does not offer a capability still has to say so. Where + * the adapter documents a refusal, assert it rather than skipping, so + * declaring a capability unsupported narrows a test instead of dropping it. + * The wording is what tells a refusal from a failed call: the repository + * the call names never exists, so a 404 must not pass for one. + */ + protected function assertRefused(callable $call): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/^\w+\(\) is not supported by /'); + $call(); + } + protected function assertEventually(callable $probe, int $timeoutMs = 15000, int $waitMs = 500): void { $start = microtime(true) * 1000; @@ -492,7 +536,7 @@ public function testGetRepository(): void } } - public function testGetDeletedRepositoryFails(): void + public function testGetNonExistingRepositoryFails(): void { $this->expectException(RepositoryNotFound::class); $this->vcsAdapter->getRepository(static::$owner, 'non-existing-repository-' . \uniqid()); @@ -500,7 +544,7 @@ public function testGetDeletedRepositoryFails(): void public function testGetRepositoryWithNonExistingOwner(): void { - $this->expectException(Exception::class); + $this->expectException(RepositoryNotFound::class); $this->vcsAdapter->getRepository('non-existing-owner-' . \uniqid(), 'non-existing-repo'); } @@ -554,7 +598,7 @@ public function testGetRepositoryName(): void public function testGetRepositoryNameWithInvalidId(): void { - $this->expectException(Exception::class); + $this->expectException(RepositoryNotFound::class); $this->vcsAdapter->getRepositoryName('99999999'); } @@ -626,7 +670,7 @@ public function testGetRepositoryContent(): void $this->assertIsString($result['sha']); $this->assertArrayHasKey('size', $result); $this->assertSame($fileContent, $result['content']); - $this->assertGreaterThan(0, $result['size']); + $this->assertSame(\strlen($fileContent), $result['size']); } finally { $this->discardRepositories($repositoryName); } @@ -715,7 +759,11 @@ public function testListRepositoryContentsNonExistingPath(): void public function testListRepositoryLanguages(): void { - $this->skipUnlessSupported(static::$supportsRepositoryLanguages, 'repository languages'); + if (!static::$supportsRepositoryLanguages) { + $this->assertRefused(fn () => $this->vcsAdapter->listRepositoryLanguages(static::$owner, 'unsupported-languages-' . \uniqid())); + + return; + } $repositoryName = 'test-list-repository-languages-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -724,6 +772,13 @@ public function testListRepositoryLanguages(): void $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'main.php', 'vcsAdapter->createFile(static::$owner, $repositoryName, 'script.js', 'console.log("test");'); + if (!static::$detectsRepositoryLanguages) { + // Nothing labelled the repository, and the files are never inspected + $this->assertSame([], $this->vcsAdapter->listRepositoryLanguages(static::$owner, $repositoryName)); + + return; + } + $languages = []; try { $this->assertEventually(function () use (&$languages, $repositoryName) { @@ -749,6 +804,8 @@ public function testListRepositoryLanguagesEmptyRepo(): void { $this->skipUnlessSupported(static::$supportsRepositoryLanguages, 'repository languages'); + // A repository with nothing in it has no languages to report, whether or + // not the provider works them out from the files $repositoryName = 'test-list-repository-languages-empty-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -789,16 +846,14 @@ public function testListBranches(): void public function testListBranchesEmptyRepository(): void { - $this->skipUnlessSupported(static::$createsEmptyRepositories, 'repositories without an initial commit'); - $repositoryName = 'test-list-branches-empty-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - $branches = $this->vcsAdapter->listBranches(static::$owner, $repositoryName); - - $this->assertIsArray($branches); - $this->assertEmpty($branches); + $this->assertSame( + static::$createsEmptyRepositories ? [] : [static::$defaultBranch], + $this->vcsAdapter->listBranches(static::$owner, $repositoryName) + ); } finally { $this->discardRepositories($repositoryName); } @@ -944,7 +999,16 @@ public function testGetLatestCommitWithInvalidBranch(): void public function testUpdateCommitStatus(): void { - $this->skipUnlessSupported(static::$supportsCommitStatuses, 'commit statuses'); + if (!static::$supportsCommitStatuses) { + $this->assertRefused(fn () => $this->vcsAdapter->updateCommitStatus( + 'unsupported-commit-statuses-' . \uniqid(), + 'abc123', + static::$owner, + 'success' + )); + + return; + } $repositoryName = 'test-update-commit-status-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1095,23 +1159,11 @@ public function testGenerateCloneCommandWithInvalidRepository(): void public function testGetOwnerNameWithoutRepositoryId(): void { - $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); - - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('')); - } - - public function testGetOwnerNameWithZeroRepositoryId(): void - { - $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); - - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', 0)); - } - - public function testGetOwnerNameWithNullRepositoryId(): void - { - $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); + $expected = static::$resolvesOwnerFromRepositoryId ? static::$existingUser : $this->ownerPath(); - $this->assertSame(static::$existingUser, $this->vcsAdapter->getOwnerName('', null)); + $this->assertSame($expected, $this->vcsAdapter->getOwnerName(static::$installationId)); + $this->assertSame($expected, $this->vcsAdapter->getOwnerName(static::$installationId, 0)); + $this->assertSame($expected, $this->vcsAdapter->getOwnerName(static::$installationId, null)); } public function testGetOwnerName(): void @@ -1134,45 +1186,47 @@ public function testGetOwnerName(): void public function testCreateRepositoryWithInvalidName(): void { - $this->skipUnlessSupported(static::$rejectsInvalidRepositoryNames, 'rejecting invalid repository names'); + $uniq = \uniqid(); + $invalidName = 'invalid name with spaces ' . $uniq; + + if (!static::$rejectsInvalidRepositoryNames) { + // GitHub replaces the spaces with hyphens rather than refusing the name + $normalizedName = 'invalid-name-with-spaces-' . $uniq; + $created = $this->vcsAdapter->createRepository(static::$owner, $invalidName, false); + + try { + $this->assertSame($normalizedName, $created['name']); + $this->assertSame($normalizedName, $this->vcsAdapter->getRepository(static::$owner, $normalizedName)['name']); + } finally { + $this->discardRepositories($normalizedName); + } + + return; + } $this->expectException(Exception::class); - $this->vcsAdapter->createRepository(static::$owner, 'invalid name with spaces', false); + $this->vcsAdapter->createRepository(static::$owner, $invalidName, false); } public function testGenerateCloneCommandWithTag(): void { - $this->skipUnlessSupported(static::$supportsTags, 'creating tags'); - - $repositoryName = 'test-clone-tag-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $directory = '/tmp/test-clone-tag-' . \uniqid(); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test Tag'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash, 'Release v1.0.0'); - - $command = $this->vcsAdapter->generateCloneCommand( - static::$owner, - $repositoryName, - 'v1.0.0', - Git::CLONE_TYPE_TAG, - $directory, - '/' - ); + // The command is built, never run, so the tag does not have to exist + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + 'test-clone-tag-' . \uniqid(), + 'v1.0.0', + Git::CLONE_TYPE_TAG, + '/tmp/test-clone-tag-' . \uniqid(), + '/' + ); - $this->assertIsString($command); - $this->assertStringContainsString('git init', $command); - $this->assertStringContainsString('git remote add origin', $command); - $this->assertStringContainsString('git config core.sparseCheckout true', $command); - $this->assertStringContainsString('refs/tags', $command); - $this->assertStringContainsString('v1.0.0', $command); - $this->assertStringContainsString('git checkout FETCH_HEAD', $command); - } finally { - $this->discardRepositories($repositoryName); - } + $this->assertIsString($command); + $this->assertStringContainsString('git init', $command); + $this->assertStringContainsString('git remote add origin', $command); + $this->assertStringContainsString('git config core.sparseCheckout true', $command); + $this->assertStringContainsString('refs/tags', $command); + $this->assertStringContainsString('v1.0.0', $command); + $this->assertStringContainsString('git checkout FETCH_HEAD', $command); } public function testSearchRepositoriesMatchesName(): void @@ -1231,7 +1285,17 @@ public function testSearchRepositories(): void public function testGetPullRequest(): void { - $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); + if (!static::$supportsPullRequestCreation) { + $this->assertRefused(fn () => $this->vcsAdapter->createPullRequest( + static::$owner, + 'unsupported-pull-requests-' . \uniqid(), + 'Test PR', + 'feature-branch', + static::$defaultBranch + )); + + return; + } $repositoryName = 'test-get-pull-request-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1307,7 +1371,15 @@ public function testGetPullRequestFiles(): void public function testGetPullRequestWithInvalidNumber(): void { - $this->skipUnlessSupported(static::$supportsPullRequestLookup, 'looking up pull requests'); + if (!static::$supportsPullRequestLookup) { + $this->assertRefused(fn () => $this->vcsAdapter->getPullRequest( + static::$owner, + 'unsupported-pull-request-lookup-' . \uniqid(), + 99999 + )); + + return; + } $repositoryName = 'test-get-pull-request-invalid-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1501,22 +1573,20 @@ public function testGetCommentInvalidId(): void public function testGetUser(): void { - $this->skipUnlessSupported(static::$supportsUserLookup, 'looking up users'); + $this->skipUnlessSupported(static::$resolvesUsersByHandle, 'resolving users by handle'); $result = $this->vcsAdapter->getUser(static::$existingUser); $this->assertIsArray($result); $this->assertArrayHasKey('id', $result); $this->assertNotEmpty($result['id']); - // GitLab reports the handle as 'username', Gitea and its forks as 'login' + // GitLab reports the handle as 'username', the others as 'login' $this->assertArrayHasKey(static::$userHandleField, $result); $this->assertSame(static::$existingUser, $result[static::$userHandleField]); } public function testGetUserWithInvalidUsername(): void { - $this->skipUnlessSupported(static::$supportsUserLookup, 'looking up users'); - $this->expectException(Exception::class); $this->vcsAdapter->getUser('non-existent-user-' . \uniqid()); } @@ -1606,7 +1676,9 @@ public function testGetRepositoryPresignedUrl(): void public function testGetRepositoryPresignedUrlWithInvalidFormat(): void { + // The repository never exists, so only the format check may throw $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/^Invalid archive format/'); $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); } @@ -1618,8 +1690,7 @@ public function testHasAccessToAllRepositories(): void public function testGetInstallationRepository(): void { if (!static::$supportsInstallationRepository) { - $this->expectException(Exception::class); - $this->vcsAdapter->getInstallationRepository('any-repo-name'); + $this->assertRefused(fn () => $this->vcsAdapter->getInstallationRepository('any-repo-name')); return; } @@ -1651,7 +1722,7 @@ public function testGetOwnerNameWithInvalidRepositoryId(): void return; } - $this->expectException(static::$repositoryNotFoundException); + $this->expectException(RepositoryNotFound::class); $this->vcsAdapter->getOwnerName('', 999999999); } @@ -1763,7 +1834,16 @@ public function testGetRepositoryTreeWithSlashInBranchName(): void public function testCreateTag(): void { - $this->skipUnlessSupported(static::$supportsTags, 'creating tags'); + if (!static::$supportsTags) { + $this->assertRefused(fn () => $this->vcsAdapter->createTag( + static::$owner, + 'unsupported-tags-' . \uniqid(), + 'v1.0.0', + 'abc123' + )); + + return; + } $repositoryName = 'test-create-tag-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1813,14 +1893,13 @@ public function testSearchRepositoriesPagination(): void public function testListTagsCommitlessRepository(): void { - $this->skipUnlessSupported(static::$createsEmptyRepositories, 'repositories without an initial commit'); - $repositoryName = 'test-list-tags-commitless-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - // No commits at all, which some providers answer differently from - // a repository that simply has no tags + // A repository with no commits, which some providers answer + // differently from one that has commits but no tags; the initial + // commit Gogs starts with carries none either $this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName)); } finally { $this->discardRepositories($repositoryName); @@ -1829,7 +1908,15 @@ public function testListTagsCommitlessRepository(): void public function testGetCommitStatuses(): void { - $this->skipUnlessSupported(static::$supportsCommitStatusLookup, 'reading commit statuses'); + if (!static::$supportsCommitStatusLookup) { + $this->assertRefused(fn () => $this->vcsAdapter->getCommitStatuses( + static::$owner, + 'unsupported-commit-status-lookup-' . \uniqid(), + 'abc123' + )); + + return; + } $repositoryName = 'test-get-commit-statuses-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1875,7 +1962,16 @@ public function testGetCommitStatusesEmptyForNewCommit(): void public function testCreateCheckRun(): void { - $this->skipUnlessSupported(static::$supportsCheckRuns, 'check runs'); + if (!static::$supportsCheckRuns) { + $this->assertRefused(fn () => $this->vcsAdapter->createCheckRun( + owner: static::$owner, + repositoryName: 'unsupported-check-runs-' . \uniqid(), + headSha: 'abc123', + name: 'ci/build', + )); + + return; + } $repositoryName = 'test-create-check-run-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1914,7 +2010,7 @@ public function testCreateCheckRun(): void $this->assertNotEmpty($fetched['url']); $this->assertNotEmpty($fetched['html_url']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateCheckRunWithInvalidRepository(): void @@ -1940,7 +2036,7 @@ public function testGetCheckRunWithInvalidId(): void $this->expectException(\Exception::class); $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, '999999999'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateTwoCheckRunsOnSameCommit(): void @@ -1980,7 +2076,7 @@ public function testCreateTwoCheckRunsOnSameCommit(): void $this->assertEquals('ci/build', $first['name']); $this->assertEquals('ci/build', $second['name']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void @@ -2023,7 +2119,7 @@ public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void $this->assertEquals('ci/build', $first['name']); $this->assertEquals('ci/build', $second['name']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateCheckRunCompleted(): void @@ -2061,7 +2157,7 @@ public function testCreateCheckRunCompleted(): void $this->assertEquals('Build passed', $checkRun['output']['title']); $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCheckRun(): void @@ -2103,7 +2199,7 @@ public function testUpdateCheckRun(): void $this->assertEquals('completed', $updated['status']); $this->assertEquals('neutral', $updated['conclusion']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCheckRunWithInvalidRepository(): void @@ -2134,7 +2230,7 @@ public function testUpdateCheckRunWithInvalidId(): void conclusion: 'success', ); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCheckRunWithMissingConclusion(): void @@ -2166,13 +2262,17 @@ public function testUpdateCheckRunWithMissingConclusion(): void status: 'completed', ); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testListNamespaces(): void { - $this->skipUnlessSupported(static::$supportsNamespaceListing, 'listing namespaces'); + if (!static::$supportsNamespaceListing) { + $this->assertRefused(fn () => $this->vcsAdapter->listNamespaces(1, 20)); + + return; + } $result = $this->vcsAdapter->listNamespaces(1, 20); @@ -2224,7 +2324,7 @@ public function testListRepositoryContentsRootSentinels(): void $this->assertEquals(array_column($empty, 'name'), array_column($dotSlash, 'name')); $this->assertEquals(array_column($empty, 'name'), array_column($repeatedDotSlash, 'name')); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetRepositoryContentRootSentinelPrefix(): void @@ -2242,7 +2342,7 @@ public function testGetRepositoryContentRootSentinelPrefix(): void $this->assertEquals($direct['content'], $prefixed['content']); $this->assertEquals($direct['content'], $repeatedPrefix['content']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testListRepositoryContentsMalformedNestedPath(): void @@ -2261,7 +2361,7 @@ public function testListRepositoryContentsMalformedNestedPath(): void $this->assertEquals(array_column($clean, 'name'), array_column($embeddedDot, 'name')); $this->assertEquals(array_column($clean, 'name'), array_column($doubleSlash, 'name')); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -2300,8 +2400,6 @@ public function testGetRepositoryContentReportsBlobSha(): void public function testGetCommitAuthorAvatar(): void { - $this->skipUnlessSupported(static::$reportsCommitAuthorAvatar, 'commit author avatars'); - $repositoryName = 'test-get-commit-avatar-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -2311,16 +2409,21 @@ public function testGetCommitAuthorAvatar(): void $commit = $this->vcsAdapter->getCommit(static::$owner, $repositoryName, $commitHash); + if (!static::$reportsCommitAuthorAvatar) { + $this->assertSame('', $commit['commitAuthorAvatar']); + + return; + } + $this->assertNotEmpty($commit['commitAuthorAvatar']); $this->assertStringContainsString(static::$avatarDomain, $commit['commitAuthorAvatar']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } + public function testGetRepositoryAfterDeleteFails(): void { - $this->skipUnlessSupported(static::$deletesRepositoriesSynchronously, 'deleting a repository straight away'); - $repositoryName = 'test-get-deleted-repository-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); @@ -2358,6 +2461,27 @@ public function testGetEventPush(): void ); } + /** + * A push lists every commit it carried; the event describes the head, not + * the first one listed. + */ + public function testGetEventPushReportsHeadCommit(): void + { + $events = $this->vcsAdapter->getEvents( + static::$pushEventName, + $this->pushPayload(static::$defaultBranch, olderCommits: ['aaa111', 'bbb222']) + ); + $this->assertCount(1, $events); + $result = $events[0]; + + $this->assertSame(self::EVENT_COMMIT_HASH, $result['commitHash']); + $this->assertSame(self::EVENT_COMMIT_MESSAGE, $result['headCommitMessage']); + $this->assertSame(self::EVENT_AUTHOR_NAME, $result['headCommitAuthorName']); + $this->assertSame(self::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); + // Providers shape the commit url differently, but each ends it with the hash + $this->assertStringEndsWith('/' . self::EVENT_COMMIT_HASH, $result['headCommitUrl']); + } + public function testGetEventPushDetectsBranchCreated(): void { $events = $this->vcsAdapter->getEvents( @@ -2413,6 +2537,27 @@ public function testGetEventPullRequestDetectsExternal(): void $this->assertTrue($result['external']); } + /** + * Every pull request action normalizes to the shared vocabulary, and each + * provider sends the three consumers act on: opened, synchronize, closed. + */ + public function testGetEventPullRequestNormalizesAction(): void + { + $vocabulary = ['opened', 'reopened', 'synchronize', 'closed']; + + $this->assertSame([], \array_diff(static::$pullRequestActions, $vocabulary)); + $this->assertSame([], \array_diff(['opened', 'synchronize', 'closed'], static::$pullRequestActions)); + + foreach (static::$pullRequestActions as $native => $normalized) { + $events = $this->vcsAdapter->getEvents( + $this->pullRequestEventFor($native), + $this->pullRequestPayload(action: $native) + ); + $this->assertCount(1, $events, "No event for the '{$native}' action"); + $this->assertSame($normalized, $events[0]['action'], "The '{$native}' action did not normalize to '{$normalized}'"); + } + } + public function testGetEventInvalidPayload(): void { $this->expectException(Exception::class);