From c32e818eaa95d9800b9f45708cd6b4cbef15a346 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 2 Mar 2026 11:07:30 +0100 Subject: [PATCH 1/4] feat: Make the PullRequestList countable --- src/GithubPullRequestList.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/GithubPullRequestList.php b/src/GithubPullRequestList.php index a012c6e..5019f6f 100644 --- a/src/GithubPullRequestList.php +++ b/src/GithubPullRequestList.php @@ -5,13 +5,14 @@ namespace Horde\GithubApiClient; use ArrayIterator; +use Countable; use IteratorAggregate; use Traversable; use OutOfBoundsException; use Stringable; /** @implements \IteratorAggregate */ -class GithubPullRequestList implements IteratorAggregate +class GithubPullRequestList implements IteratorAggregate, Countable { /** * @var GithubPullRequest[] @@ -35,4 +36,9 @@ public function getIterator(): ArrayIterator { return new ArrayIterator($this->prs); } + + public function count(): int + { + return count($this->prs); + } } From fd89e6744477c01adf03c8ab1c3e083733e41f0d Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 2 Mar 2026 11:29:55 +0100 Subject: [PATCH 2/4] fix: Remove ephemeral script --- create-pr.php | 227 -------------------------------------------------- 1 file changed, 227 deletions(-) delete mode 100755 create-pr.php diff --git a/create-pr.php b/create-pr.php deleted file mode 100755 index 8051c59..0000000 --- a/create-pr.php +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env php -setInstance(ClientInterface::class, new CurlClient(new ResponseFactory(), new StreamFactory(), new Options())); -$injector->setInstance(RequestFactoryInterface::class, new RequestFactory()); -$injector->setInstance(StreamFactoryInterface::class, new StreamFactory()); -$injector->setInstance(GithubApiConfig::class, new GithubApiConfig(accessToken: $githubToken)); - -$client = $injector->get(GithubApiClient::class); - -// Repository details -$repo = GithubRepository::fromFullName('horde/githubapiclient'); - -// PR details -$title = 'feat: add comprehensive pull request management API'; - -$body = <<<'MARKDOWN' -## Summary - -This PR adds comprehensive pull request management capabilities to the GitHub API Client, transforming it from a basic client into a full-featured PR automation tool. - -## Features Added - -### Pull Request Operations -- ✅ Create pull requests (including draft PRs and from forks) -- ✅ List pull requests with filters (base branch, head ref, state) -- ✅ Get detailed pull request information -- ✅ Update pull requests (title, body, base branch, state) -- ✅ Merge pull requests (merge, squash, rebase methods) -- ✅ Close pull requests -- ✅ Reopen closed pull requests - -### Comment Management -- ✅ List all comments on pull requests -- ✅ Create comments -- ✅ Update comments -- ✅ Delete comments - -### Review Management -- ✅ List pull request reviews -- ✅ Request reviewers (users and teams) -- ✅ Support for all review states (APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED) - -### Status Checks & CI/CD -- ✅ Get combined commit status -- ✅ List GitHub Actions check runs -- ✅ Monitor pipeline status and conclusions - -### Label Management -- ✅ List labels on issues/pull requests -- ✅ Add labels -- ✅ Set (replace) all labels -- ✅ Remove labels - -## Technical Implementation - -### Architecture -- **Request Factory Pattern**: Each API endpoint has a dedicated request factory -- **Value Objects**: Immutable domain objects with static factory methods -- **DTOs**: Clean data transfer objects for complex parameters -- **Typed Collections**: All collections implement `Iterator` and `Countable` -- **PHP 8.2+ Features**: Named parameters, readonly properties, strict types -- **PSR Compliant**: PSR-7, PSR-17, PSR-18 - -### Code Quality -- **71 unit tests** with **249 assertions** - all passing ✅ -- **52 files changed**: 5,217 insertions, 7 deletions -- **PER-1 coding standards** throughout -- **Conventional Commits** for all commits - -## Documentation - -### Added Documentation Files -- **README.md**: Comprehensive usage guide with examples for all features -- **doc/API.md**: Complete API reference (574 lines) -- **doc/MIGRATION.md**: Upgrade guide with backwards compatibility notes (305 lines) -- **bin/demo-client.php**: Enhanced with 10 working examples - -## Breaking Changes - -**None** - This is a backwards-compatible addition. Existing code continues to work unchanged. - -**Optional Enhancement**: Add `StreamFactoryInterface` parameter to constructor to enable write operations: -```php -$client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); -``` - -## Example Usage - -### Create a Pull Request -```php -use Horde\GithubApiClient\CreatePullRequestParams; - -$params = new CreatePullRequestParams( - title: 'Add new feature', - head: 'feature-branch', - base: 'main', - body: 'This PR adds...' -); -$pr = $client->createPullRequest($repo, $params); -``` - -### Merge a Pull Request -```php -use Horde\GithubApiClient\MergePullRequestParams; - -$params = new MergePullRequestParams( - commitTitle: 'feat: add feature', - mergeMethod: 'squash' -); -$result = $client->mergePullRequest($repo, 123, $params); -``` - -### Manage Comments and Reviews -```php -// Add a comment -$comment = $client->createPullRequestComment($repo, 123, 'LGTM!'); - -// Request reviewers -$client->requestReviewers($repo, 123, ['reviewer1', 'reviewer2']); - -// Check CI status -$status = $client->getCombinedStatus($repo, 'main'); -echo "Status: {$status->state}\n"; -``` - -## Test Plan - -- [x] All 71 unit tests passing -- [x] Demo client tested with real GitHub API -- [x] Documentation reviewed and examples verified -- [x] Backwards compatibility verified -- [x] Code follows PER-1 and Conventional Commits standards - -## Commits - -This PR includes 10 well-structured commits: -1. Enhanced pull request API with user and label support -2. Pull request update capability -3. Pull request comment management -4. Pull request review management -5. Commit status and check runs support -6. Label management support -7. Pull request merge and close support -8. Comprehensive documentation and examples -9. Create and reopen pull request functionality -10. Factory method and constructor documentation improvements - -## Checklist - -- [x] Code follows project coding standards (PER-1) -- [x] Unit tests added and passing (71 tests, 249 assertions) -- [x] Documentation updated (README, API reference, migration guide) -- [x] Backwards compatible (existing code unaffected) -- [x] Conventional Commits used for all commits -- [x] Demo client includes working examples -- [x] No breaking changes introduced - -## Related Issues - -Closes #[issue number if applicable] -MARKDOWN; - -echo "Creating pull request...\n"; -echo "Repository: horde/githubapiclient\n"; -echo "Head: feat/enhanced-pull-request-api\n"; -echo "Base: FRAMEWORK_6_0\n\n"; - -try { - $params = new CreatePullRequestParams( - title: $title, - head: 'feat/enhanced-pull-request-api', - base: 'FRAMEWORK_6_0', - body: $body, - draft: false, - maintainerCanModify: true - ); - - $pr = $client->createPullRequest($repo, $params); - - echo "✅ Pull request created successfully!\n\n"; - echo "PR #{$pr->number}: {$pr->title}\n"; - echo "URL: {$pr->htmlUrl}\n"; - echo "State: {$pr->state}\n"; - echo "Author: {$pr->author->login}\n"; - echo "\nYou can view the PR at: {$pr->htmlUrl}\n"; - -} catch (\Exception $e) { - echo "❌ Error creating pull request:\n"; - echo $e->getMessage() . "\n"; - exit(1); -} From bc35f6ec853cd92e4852f410abd373fb8ef9fd8f Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 2 Mar 2026 11:30:53 +0100 Subject: [PATCH 3/4] feat: Support necessary API calls to implement a Github App integration - does not include JWT generator --- src/CreateInstallationAccessTokenParams.php | 49 ++ ...eInstallationAccessTokenRequestFactory.php | 58 +++ src/GetAuthenticatedAppRequestFactory.php | 45 ++ src/GithubApiClient.php | 85 ++++ src/GithubApiConfig.php | 4 +- src/GithubApp.php | 64 +++ src/GithubInstallation.php | 61 +++ src/GithubInstallationList.php | 72 +++ src/InstallationAccessToken.php | 49 ++ src/ListInstallationsRequestFactory.php | 45 ++ ...reateInstallationAccessTokenParamsTest.php | 116 +++++ test/unit/GithubApiClientAppAuthTest.php | 437 ++++++++++++++++++ test/unit/GithubApiConfigTest.php | 95 ++++ test/unit/GithubAppTest.php | 138 ++++++ test/unit/GithubInstallationListTest.php | 135 ++++++ test/unit/GithubInstallationTest.php | 131 ++++++ test/unit/InstallationAccessTokenTest.php | 138 ++++++ 17 files changed, 1721 insertions(+), 1 deletion(-) create mode 100644 src/CreateInstallationAccessTokenParams.php create mode 100644 src/CreateInstallationAccessTokenRequestFactory.php create mode 100644 src/GetAuthenticatedAppRequestFactory.php create mode 100644 src/GithubApp.php create mode 100644 src/GithubInstallation.php create mode 100644 src/GithubInstallationList.php create mode 100644 src/InstallationAccessToken.php create mode 100644 src/ListInstallationsRequestFactory.php create mode 100644 test/unit/CreateInstallationAccessTokenParamsTest.php create mode 100644 test/unit/GithubApiClientAppAuthTest.php create mode 100644 test/unit/GithubApiConfigTest.php create mode 100644 test/unit/GithubAppTest.php create mode 100644 test/unit/GithubInstallationListTest.php create mode 100644 test/unit/GithubInstallationTest.php create mode 100644 test/unit/InstallationAccessTokenTest.php diff --git a/src/CreateInstallationAccessTokenParams.php b/src/CreateInstallationAccessTokenParams.php new file mode 100644 index 0000000..bee4a07 --- /dev/null +++ b/src/CreateInstallationAccessTokenParams.php @@ -0,0 +1,49 @@ + $repositories List of repository names to grant access to (optional) + * @param array $permissions Permissions to grant (optional) + */ + public function __construct( + public readonly array $repositories = [], + public readonly array $permissions = [] + ) {} + + /** + * Convert to array for API request + * + * @return array + */ + public function toArray(): array + { + $data = []; + + if ($this->repositories !== []) { + $data['repositories'] = $this->repositories; + } + + if ($this->permissions !== []) { + $data['permissions'] = $this->permissions; + } + + return $data; + } +} diff --git a/src/CreateInstallationAccessTokenRequestFactory.php b/src/CreateInstallationAccessTokenRequestFactory.php new file mode 100644 index 0000000..032f195 --- /dev/null +++ b/src/CreateInstallationAccessTokenRequestFactory.php @@ -0,0 +1,58 @@ +config->endpoint, + $this->installationId + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/GetAuthenticatedAppRequestFactory.php b/src/GetAuthenticatedAppRequestFactory.php new file mode 100644 index 0000000..02b77ef --- /dev/null +++ b/src/GetAuthenticatedAppRequestFactory.php @@ -0,0 +1,45 @@ +config->endpoint); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + + return $request; + } +} diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index cd57672..ebd2ab1 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -844,6 +844,91 @@ public function getCurrentUser(): GithubUser } } + /** + * Create an installation access token for GitHub App + * + * @param int $installationId The installation ID + * @param CreateInstallationAccessTokenParams $params Optional parameters + * @return InstallationAccessToken + * @throws Exception + */ + public function createInstallationAccessToken( + int $installationId, + CreateInstallationAccessTokenParams $params = new CreateInstallationAccessTokenParams() + ): InstallationAccessToken + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createInstallationAccessToken'); + } + + $requestFactory = new CreateInstallationAccessTokenRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $installationId, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + return InstallationAccessToken::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * List all installations of the authenticated GitHub App + * + * @return GithubInstallationList + * @throws Exception + */ + public function listInstallations(): GithubInstallationList + { + $requestFactory = new ListInstallationsRequestFactory( + $this->requestFactory, + $this->config + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $installations = []; + foreach ($data as $installationData) { + $installations[] = GithubInstallation::fromApiResponse($installationData); + } + return new GithubInstallationList($installations); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Get the authenticated GitHub App + * + * @return GithubApp + * @throws Exception + */ + public function getAuthenticatedApp(): GithubApp + { + $requestFactory = new GetAuthenticatedAppRequestFactory( + $this->requestFactory, + $this->config + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubApp::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + /** * @param array $repos * @param string $json diff --git a/src/GithubApiConfig.php b/src/GithubApiConfig.php index 45dfcc6..94331bf 100644 --- a/src/GithubApiConfig.php +++ b/src/GithubApiConfig.php @@ -13,6 +13,8 @@ public function __construct( public readonly string $endpoint = 'https://api.github.com', // Default to no access token public readonly string $accessToken = '', - public readonly string $apiVersion = '2022-11-28' + public readonly string $apiVersion = '2022-11-28', + // Default to no JWT (for GitHub App authentication) + public readonly string $jwt = '' ) {} } diff --git a/src/GithubApp.php b/src/GithubApp.php new file mode 100644 index 0000000..5daa5a9 --- /dev/null +++ b/src/GithubApp.php @@ -0,0 +1,64 @@ +slug; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $owner = isset($data->owner) ? GithubUser::fromApiResponse($data->owner) : new GithubUser('', 0, '', '', ''); + + return new self( + id: $data->id ?? 0, + slug: $data->slug ?? '', + name: $data->name ?? '', + owner: $owner, + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '' + ); + } +} diff --git a/src/GithubInstallation.php b/src/GithubInstallation.php new file mode 100644 index 0000000..62af19f --- /dev/null +++ b/src/GithubInstallation.php @@ -0,0 +1,61 @@ +id; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $account = isset($data->account) ? GithubUser::fromApiResponse($data->account) : new GithubUser('', 0, '', '', ''); + + return new self( + id: $data->id ?? 0, + account: $account, + repositorySelection: $data->repository_selection ?? '', + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '' + ); + } +} diff --git a/src/GithubInstallationList.php b/src/GithubInstallationList.php new file mode 100644 index 0000000..ed2efb1 --- /dev/null +++ b/src/GithubInstallationList.php @@ -0,0 +1,72 @@ + $installations + */ + public function __construct( + private array $installations = [] + ) {} + + public function current(): GithubInstallation + { + return $this->installations[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->installations[$this->position]); + } + + public function count(): int + { + return count($this->installations); + } + + /** + * Get all installations as array + * + * @return array + */ + public function toArray(): array + { + return $this->installations; + } +} diff --git a/src/InstallationAccessToken.php b/src/InstallationAccessToken.php new file mode 100644 index 0000000..eb5e658 --- /dev/null +++ b/src/InstallationAccessToken.php @@ -0,0 +1,49 @@ + $permissions Granted permissions + * @param string $repositorySelection Repository selection type (all/selected) + */ + public function __construct( + public readonly string $token, + public readonly string $expiresAt, + public readonly array $permissions, + public readonly string $repositorySelection + ) {} + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + token: $data->token ?? '', + expiresAt: $data->expires_at ?? '', + permissions: (array) ($data->permissions ?? []), + repositorySelection: $data->repository_selection ?? '' + ); + } +} diff --git a/src/ListInstallationsRequestFactory.php b/src/ListInstallationsRequestFactory.php new file mode 100644 index 0000000..2b5689a --- /dev/null +++ b/src/ListInstallationsRequestFactory.php @@ -0,0 +1,45 @@ +config->endpoint); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + + return $request; + } +} diff --git a/test/unit/CreateInstallationAccessTokenParamsTest.php b/test/unit/CreateInstallationAccessTokenParamsTest.php new file mode 100644 index 0000000..f334d9e --- /dev/null +++ b/test/unit/CreateInstallationAccessTokenParamsTest.php @@ -0,0 +1,116 @@ +assertSame([], $params->repositories); + $this->assertSame([], $params->permissions); + } + + public function testConstructorWithRepositoriesOnly(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2'] + ); + + $this->assertSame(['repo1', 'repo2'], $params->repositories); + $this->assertSame([], $params->permissions); + } + + public function testConstructorWithPermissionsOnly(): void + { + $params = new CreateInstallationAccessTokenParams( + permissions: ['contents' => 'read', 'issues' => 'write'] + ); + + $this->assertSame([], $params->repositories); + $this->assertSame(['contents' => 'read', 'issues' => 'write'], $params->permissions); + } + + public function testConstructorWithBothRepositoriesAndPermissions(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['my-repo'], + permissions: ['pull_requests' => 'write', 'metadata' => 'read'] + ); + + $this->assertSame(['my-repo'], $params->repositories); + $this->assertSame(['pull_requests' => 'write', 'metadata' => 'read'], $params->permissions); + } + + public function testToArrayExcludesEmptyArrays(): void + { + $params = new CreateInstallationAccessTokenParams(); + + $array = $params->toArray(); + + $this->assertSame([], $array); + $this->assertArrayNotHasKey('repositories', $array); + $this->assertArrayNotHasKey('permissions', $array); + } + + public function testToArrayIncludesNonEmptyRepositories(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2', 'repo3'] + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('repositories', $array); + $this->assertSame(['repo1', 'repo2', 'repo3'], $array['repositories']); + $this->assertArrayNotHasKey('permissions', $array); + } + + public function testToArrayIncludesNonEmptyPermissions(): void + { + $params = new CreateInstallationAccessTokenParams( + permissions: ['contents' => 'write', 'issues' => 'read'] + ); + + $array = $params->toArray(); + + $this->assertArrayNotHasKey('repositories', $array); + $this->assertArrayHasKey('permissions', $array); + $this->assertSame(['contents' => 'write', 'issues' => 'read'], $array['permissions']); + } + + public function testToArrayIncludesBothWhenNonEmpty(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['test-repo'], + permissions: ['metadata' => 'read'] + ); + + $array = $params->toArray(); + + $this->assertSame([ + 'repositories' => ['test-repo'], + 'permissions' => ['metadata' => 'read'] + ], $array); + } +} diff --git a/test/unit/GithubApiClientAppAuthTest.php b/test/unit/GithubApiClientAppAuthTest.php new file mode 100644 index 0000000..bebbe6c --- /dev/null +++ b/test/unit/GithubApiClientAppAuthTest.php @@ -0,0 +1,437 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $responseBody = json_encode([ + 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', + 'expires_at' => '2026-03-02T13:00:00Z', + 'permissions' => ['contents' => 'read', 'metadata' => 'read'], + 'repository_selection' => 'all' + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $token = $client->createInstallationAccessToken(12345); + + $this->assertInstanceOf(InstallationAccessToken::class, $token); + $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); + $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); + $this->assertSame('all', $token->repositorySelection); + } + + public function testCreateInstallationAccessTokenWithOptionalParameters(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $responseBody = json_encode([ + 'token' => 'ghs_specific_repos', + 'expires_at' => '2026-03-02T14:00:00Z', + 'permissions' => ['contents' => 'write', 'issues' => 'write'], + 'repository_selection' => 'selected' + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2'], + permissions: ['contents' => 'write', 'issues' => 'write'] + ); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $token = $client->createInstallationAccessToken(67890, $params); + + $this->assertInstanceOf(InstallationAccessToken::class, $token); + $this->assertSame('ghs_specific_repos', $token->token); + $this->assertSame('selected', $token->repositorySelection); + } + + public function testCreateInstallationAccessTokenRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, null); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createInstallationAccessToken'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn401Unauthorized(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Bad credentials']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn403Forbidden(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Forbidden']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(403); + $response->method('getReasonPhrase')->willReturn('Forbidden'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('403 Forbidden'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn404NotFound(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Not Found']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->createInstallationAccessToken(99999); + } + + // Tests for listInstallations + + public function testListInstallationsSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBody = json_encode([ + [ + 'id' => 111, + 'account' => ['login' => 'org1', 'id' => 1, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z' + ], + [ + 'id' => 222, + 'account' => ['login' => 'org2', 'id' => 2, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'selected', + 'created_at' => '2026-01-02T00:00:00Z', + 'updated_at' => '2026-01-02T00:00:00Z' + ] + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertInstanceOf(GithubInstallationList::class, $installations); + $this->assertCount(2, $installations); + + $installationArray = $installations->toArray(); + $this->assertSame(111, $installationArray[0]->id); + $this->assertSame('org1', $installationArray[0]->account->login); + $this->assertSame(222, $installationArray[1]->id); + $this->assertSame('org2', $installationArray[1]->account->login); + } + + public function testListInstallationsEmptyArray(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn('[]'); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertInstanceOf(GithubInstallationList::class, $installations); + $this->assertCount(0, $installations); + } + + public function testListInstallationsMultipleInstallations(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $installationsData = []; + for ($i = 1; $i <= 5; $i++) { + $installationsData[] = [ + 'id' => $i * 100, + 'account' => ['login' => "org{$i}", 'id' => $i, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z' + ]; + } + + $stream->method('__toString')->willReturn(json_encode($installationsData)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertCount(5, $installations); + } + + public function testListInstallationsErrorHandling(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn(json_encode(['message' => 'Unauthorized'])); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->listInstallations(); + } + + // Tests for getAuthenticatedApp + + public function testGetAuthenticatedAppSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBody = json_encode([ + 'id' => 123456, + 'slug' => 'my-github-app', + 'name' => 'My GitHub App', + 'owner' => [ + 'login' => 'app-owner', + 'id' => 789, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/789', + 'html_url' => 'https://github.com/app-owner', + 'type' => 'User' + ], + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2026-03-01T00:00:00Z' + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $app = $client->getAuthenticatedApp(); + + $this->assertInstanceOf(GithubApp::class, $app); + $this->assertSame(123456, $app->id); + $this->assertSame('my-github-app', $app->slug); + $this->assertSame('My GitHub App', $app->name); + $this->assertSame('app-owner', $app->owner->login); + } + + public function testGetAuthenticatedAppErrorHandling(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn(json_encode(['message' => 'Bad credentials'])); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->getAuthenticatedApp(); + } +} diff --git a/test/unit/GithubApiConfigTest.php b/test/unit/GithubApiConfigTest.php new file mode 100644 index 0000000..db09678 --- /dev/null +++ b/test/unit/GithubApiConfigTest.php @@ -0,0 +1,95 @@ +assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('', $config->jwt); + } + + public function testWithAccessTokenOnly(): void + { + $config = new GithubApiConfig(accessToken: 'ghp_test123'); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('ghp_test123', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('', $config->jwt); + } + + public function testWithJwtOnly(): void + { + $config = new GithubApiConfig(jwt: 'eyJhbGc.eyJpc3M.signature'); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); + } + + public function testWithBothAccessTokenAndJwt(): void + { + $config = new GithubApiConfig( + accessToken: 'ghp_test123', + jwt: 'eyJhbGc.eyJpc3M.signature' + ); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('ghp_test123', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); + } + + public function testWithCustomEndpointAndApiVersion(): void + { + $config = new GithubApiConfig( + endpoint: 'https://github.example.com/api/v3', + accessToken: 'token123', + apiVersion: '2023-01-01', + jwt: 'jwt123' + ); + + $this->assertSame('https://github.example.com/api/v3', $config->endpoint); + $this->assertSame('token123', $config->accessToken); + $this->assertSame('2023-01-01', $config->apiVersion); + $this->assertSame('jwt123', $config->jwt); + } + + public function testPropertiesAreReadonly(): void + { + $config = new GithubApiConfig(); + + $reflection = new \ReflectionClass($config); + $properties = $reflection->getProperties(); + + foreach ($properties as $property) { + $this->assertTrue($property->isReadOnly(), "Property {$property->getName()} should be readonly"); + } + } +} diff --git a/test/unit/GithubAppTest.php b/test/unit/GithubAppTest.php new file mode 100644 index 0000000..bfac31e --- /dev/null +++ b/test/unit/GithubAppTest.php @@ -0,0 +1,138 @@ +assertSame(456, $app->id); + $this->assertSame('my-awesome-app', $app->slug); + $this->assertSame('My Awesome App', $app->name); + $this->assertSame($owner, $app->owner); + $this->assertSame('2025-01-15T10:00:00Z', $app->createdAt); + $this->assertSame('2026-02-20T15:30:00Z', $app->updatedAt); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'id' => 789, + 'slug' => 'ci-bot', + 'name' => 'CI Bot', + 'owner' => [ + 'login' => 'org-name', + 'id' => 999, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', + 'html_url' => 'https://github.com/org-name', + 'type' => 'Organization' + ], + 'created_at' => '2024-06-01T00:00:00Z', + 'updated_at' => '2026-03-01T12:00:00Z' + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertSame(789, $app->id); + $this->assertSame('ci-bot', $app->slug); + $this->assertSame('CI Bot', $app->name); + $this->assertSame('org-name', $app->owner->login); + $this->assertSame(999, $app->owner->id); + $this->assertSame('Organization', $app->owner->type); + $this->assertSame('2024-06-01T00:00:00Z', $app->createdAt); + $this->assertSame('2026-03-01T12:00:00Z', $app->updatedAt); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'id' => 1 + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertSame(1, $app->id); + $this->assertSame('', $app->slug); + $this->assertSame('', $app->name); + $this->assertSame('', $app->owner->login); + $this->assertSame(0, $app->owner->id); + $this->assertSame('', $app->createdAt); + $this->assertSame('', $app->updatedAt); + } + + public function testToStringReturnsSlug(): void + { + $owner = new GithubUser('test', 1, '', '', ''); + $app = new GithubApp( + id: 100, + slug: 'test-app-slug', + name: 'Test App', + owner: $owner, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + ); + + $this->assertSame('test-app-slug', (string) $app); + $this->assertSame('test-app-slug', $app->__toString()); + } + + public function testOwnerGithubUserParsing(): void + { + $data = json_decode(json_encode([ + 'id' => 222, + 'slug' => 'automation-app', + 'name' => 'Automation App', + 'owner' => [ + 'login' => 'bot-user', + 'id' => 333, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/333', + 'html_url' => 'https://github.com/bot-user', + 'type' => 'Bot' + ], + 'created_at' => '2025-12-01T00:00:00Z', + 'updated_at' => '2026-01-15T00:00:00Z' + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertInstanceOf(GithubUser::class, $app->owner); + $this->assertSame('bot-user', $app->owner->login); + $this->assertSame(333, $app->owner->id); + $this->assertSame('Bot', $app->owner->type); + } +} diff --git a/test/unit/GithubInstallationListTest.php b/test/unit/GithubInstallationListTest.php new file mode 100644 index 0000000..f0134b0 --- /dev/null +++ b/test/unit/GithubInstallationListTest.php @@ -0,0 +1,135 @@ +assertCount(0, $list); + $this->assertSame([], $list->toArray()); + } + + public function testIterationWithForeach(): void + { + $account1 = new GithubUser('user1', 1, '', '', ''); + $account2 = new GithubUser('user2', 2, '', '', ''); + + $installation1 = new GithubInstallation(100, $account1, 'all', '2026-01-01', '2026-01-01'); + $installation2 = new GithubInstallation(200, $account2, 'selected', '2026-01-02', '2026-01-02'); + + $list = new GithubInstallationList([$installation1, $installation2]); + + $iterations = 0; + $ids = []; + + foreach ($list as $installation) { + $iterations++; + $ids[] = $installation->id; + } + + $this->assertSame(2, $iterations); + $this->assertSame([100, 200], $ids); + } + + public function testCountMethod(): void + { + $account = new GithubUser('test', 1, '', '', ''); + + $installation1 = new GithubInstallation(1, $account, 'all', '', ''); + $installation2 = new GithubInstallation(2, $account, 'all', '', ''); + $installation3 = new GithubInstallation(3, $account, 'all', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2, $installation3]); + + $this->assertCount(3, $list); + $this->assertSame(3, $list->count()); + } + + public function testToArrayMethod(): void + { + $account = new GithubUser('test', 1, '', '', ''); + + $installation1 = new GithubInstallation(111, $account, 'all', '', ''); + $installation2 = new GithubInstallation(222, $account, 'selected', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2]); + $array = $list->toArray(); + + $this->assertIsArray($array); + $this->assertCount(2, $array); + $this->assertSame($installation1, $array[0]); + $this->assertSame($installation2, $array[1]); + } + + public function testMultipleInstallations(): void + { + $installations = []; + for ($i = 1; $i <= 5; $i++) { + $account = new GithubUser("user{$i}", $i, '', '', ''); + $installations[] = new GithubInstallation($i * 100, $account, 'all', '', ''); + } + + $list = new GithubInstallationList($installations); + + $this->assertCount(5, $list); + + $index = 0; + foreach ($list as $key => $installation) { + $this->assertSame($index, $key); + $this->assertSame(($index + 1) * 100, $installation->id); + $index++; + } + } + + public function testIteratorPositionTracking(): void + { + $account = new GithubUser('test', 1, '', '', ''); + $installation1 = new GithubInstallation(1, $account, 'all', '', ''); + $installation2 = new GithubInstallation(2, $account, 'all', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2]); + + // First iteration + $list->rewind(); + $this->assertTrue($list->valid()); + $this->assertSame(0, $list->key()); + $this->assertSame($installation1, $list->current()); + + // Move to next + $list->next(); + $this->assertTrue($list->valid()); + $this->assertSame(1, $list->key()); + $this->assertSame($installation2, $list->current()); + + // Move past end + $list->next(); + $this->assertFalse($list->valid()); + + // Rewind and iterate again + $list->rewind(); + $this->assertTrue($list->valid()); + $this->assertSame(0, $list->key()); + $this->assertSame($installation1, $list->current()); + } +} diff --git a/test/unit/GithubInstallationTest.php b/test/unit/GithubInstallationTest.php new file mode 100644 index 0000000..fa80e59 --- /dev/null +++ b/test/unit/GithubInstallationTest.php @@ -0,0 +1,131 @@ +assertSame(12345, $installation->id); + $this->assertSame($account, $installation->account); + $this->assertSame('all', $installation->repositorySelection); + $this->assertSame('2026-01-01T10:00:00Z', $installation->createdAt); + $this->assertSame('2026-03-02T12:00:00Z', $installation->updatedAt); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'id' => 67890, + 'account' => [ + 'login' => 'my-org', + 'id' => 999, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', + 'html_url' => 'https://github.com/my-org', + 'type' => 'Organization' + ], + 'repository_selection' => 'selected', + 'created_at' => '2025-06-15T08:30:00Z', + 'updated_at' => '2026-02-20T14:45:00Z' + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertSame(67890, $installation->id); + $this->assertSame('my-org', $installation->account->login); + $this->assertSame(999, $installation->account->id); + $this->assertSame('Organization', $installation->account->type); + $this->assertSame('selected', $installation->repositorySelection); + $this->assertSame('2025-06-15T08:30:00Z', $installation->createdAt); + $this->assertSame('2026-02-20T14:45:00Z', $installation->updatedAt); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'id' => 111 + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertSame(111, $installation->id); + $this->assertSame('', $installation->account->login); + $this->assertSame(0, $installation->account->id); + $this->assertSame('', $installation->repositorySelection); + $this->assertSame('', $installation->createdAt); + $this->assertSame('', $installation->updatedAt); + } + + public function testToStringReturnsStringOfId(): void + { + $account = new GithubUser('test', 1, '', '', ''); + $installation = new GithubInstallation( + id: 54321, + account: $account, + repositorySelection: 'all', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + ); + + $this->assertSame('54321', (string) $installation); + $this->assertSame('54321', $installation->__toString()); + } + + public function testAccountGithubUserParsing(): void + { + $data = json_decode(json_encode([ + 'id' => 123, + 'account' => [ + 'login' => 'bot-account', + 'id' => 777, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/777', + 'html_url' => 'https://github.com/bot-account', + 'type' => 'Bot' + ], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-02-01T00:00:00Z' + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertInstanceOf(GithubUser::class, $installation->account); + $this->assertSame('bot-account', $installation->account->login); + $this->assertSame(777, $installation->account->id); + $this->assertSame('Bot', $installation->account->type); + } +} diff --git a/test/unit/InstallationAccessTokenTest.php b/test/unit/InstallationAccessTokenTest.php new file mode 100644 index 0000000..54aedc1 --- /dev/null +++ b/test/unit/InstallationAccessTokenTest.php @@ -0,0 +1,138 @@ + 'read', 'issues' => 'write'], + repositorySelection: 'selected' + ); + + $this->assertSame('ghs_test123', $token->token); + $this->assertSame('2026-03-02T12:00:00Z', $token->expiresAt); + $this->assertSame(['contents' => 'read', 'issues' => 'write'], $token->permissions); + $this->assertSame('selected', $token->repositorySelection); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', + 'expires_at' => '2026-03-02T13:00:00Z', + 'permissions' => [ + 'contents' => 'write', + 'issues' => 'read', + 'metadata' => 'read' + ], + 'repository_selection' => 'all' + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); + $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); + $this->assertSame([ + 'contents' => 'write', + 'issues' => 'read', + 'metadata' => 'read' + ], $token->permissions); + $this->assertSame('all', $token->repositorySelection); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_minimal', + 'expires_at' => '2026-03-02T14:00:00Z' + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertSame('ghs_minimal', $token->token); + $this->assertSame('2026-03-02T14:00:00Z', $token->expiresAt); + $this->assertSame([], $token->permissions); + $this->assertSame('', $token->repositorySelection); + } + + public function testPermissionsArrayHandling(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_test', + 'expires_at' => '2026-03-02T15:00:00Z', + 'permissions' => [ + 'pull_requests' => 'write', + 'checks' => 'read' + ], + 'repository_selection' => 'selected' + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertIsArray($token->permissions); + $this->assertCount(2, $token->permissions); + $this->assertArrayHasKey('pull_requests', $token->permissions); + $this->assertArrayHasKey('checks', $token->permissions); + } + + public function testRepositorySelectionValues(): void + { + // Test 'all' selection + $dataAll = json_decode(json_encode([ + 'token' => 'ghs_all', + 'expires_at' => '2026-03-02T16:00:00Z', + 'repository_selection' => 'all' + ])); + + $tokenAll = InstallationAccessToken::fromApiResponse($dataAll); + $this->assertSame('all', $tokenAll->repositorySelection); + + // Test 'selected' selection + $dataSelected = json_decode(json_encode([ + 'token' => 'ghs_selected', + 'expires_at' => '2026-03-02T17:00:00Z', + 'repository_selection' => 'selected' + ])); + + $tokenSelected = InstallationAccessToken::fromApiResponse($dataSelected); + $this->assertSame('selected', $tokenSelected->repositorySelection); + } + + public function testDoesNotImplementStringable(): void + { + $token = new InstallationAccessToken( + token: 'ghs_secret', + expiresAt: '2026-03-02T18:00:00Z', + permissions: [], + repositorySelection: 'all' + ); + + // Verify token doesn't implement Stringable (for security) + $reflection = new \ReflectionClass($token); + $interfaces = $reflection->getInterfaceNames(); + + $this->assertNotContains('Stringable', $interfaces); + } +} From 88e6c8c3d9dd42772d25690cd98096a6c98b7555 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 2 Mar 2026 11:42:33 +0100 Subject: [PATCH 4/4] style: Refine by php-cs-fixer --- .horde.yml | 4 + bin/demo-client.php | 6 +- bin/demo-token-info.php | 12 +- src/CreateInstallationAccessTokenParams.php | 98 +- ...eInstallationAccessTokenRequestFactory.php | 116 +-- src/GetAuthenticatedAppRequestFactory.php | 90 +- src/GithubApiClient.php | 3 +- src/GithubApp.php | 128 +-- src/GithubInstallation.php | 122 +-- src/GithubInstallationList.php | 144 +-- src/InstallationAccessToken.php | 98 +- src/ListInstallationsRequestFactory.php | 90 +- ...reateInstallationAccessTokenParamsTest.php | 232 ++--- test/unit/CreatePullRequestParamsTest.php | 4 +- test/unit/GithubApiClientAppAuthTest.php | 874 +++++++++--------- .../unit/GithubApiClientErrorHandlingTest.php | 2 +- test/unit/GithubApiConfigTest.php | 190 ++-- test/unit/GithubAppTest.php | 276 +++--- test/unit/GithubCheckRunTest.php | 2 +- test/unit/GithubCommentTest.php | 6 +- test/unit/GithubInstallationListTest.php | 270 +++--- test/unit/GithubInstallationTest.php | 262 +++--- test/unit/GithubLabelTest.php | 4 +- test/unit/GithubRepositoryTest.php | 2 +- test/unit/GithubReviewTest.php | 6 +- test/unit/GithubStatusTest.php | 10 +- test/unit/GithubUserTest.php | 4 +- test/unit/InstallationAccessTokenTest.php | 276 +++--- test/unit/MergeTest.php | 6 +- 29 files changed, 1670 insertions(+), 1667 deletions(-) diff --git a/.horde.yml b/.horde.yml index 5e1a6b1..4ece410 100644 --- a/.horde.yml +++ b/.horde.yml @@ -36,3 +36,7 @@ dependencies: phpstan/phpstan: ^2 nocommands: - bin/demo-client.php + +quality: + phpstan: + level: 5 diff --git a/bin/demo-client.php b/bin/demo-client.php index 0c9bc75..f10a6a6 100755 --- a/bin/demo-client.php +++ b/bin/demo-client.php @@ -153,9 +153,9 @@ title: 'Demo PR - API Client Test', head: $headBranch, base: $baseBranch, - body: "This is a demo pull request created by the GitHub API Client.\n\n" . - "Created at: " . date('Y-m-d H:i:s') . "\n" . - "This PR can be safely closed.", + body: "This is a demo pull request created by the GitHub API Client.\n\n" + . "Created at: " . date('Y-m-d H:i:s') . "\n" + . "This PR can be safely closed.", draft: (getenv('PR_DRAFT') === '1'), maintainerCanModify: true ); diff --git a/bin/demo-token-info.php b/bin/demo-token-info.php index 186a775..7a7f1a6 100755 --- a/bin/demo-token-info.php +++ b/bin/demo-token-info.php @@ -45,12 +45,12 @@ try { echo "📊 Checking Rate Limit...\n"; $rateLimit = $client->getRateLimit(); - + echo " ├─ Limit: " . number_format($rateLimit->limit) . " requests/hour\n"; echo " ├─ Used: " . number_format($rateLimit->used) . " requests\n"; echo " ├─ Remaining: " . number_format($rateLimit->remaining) . " requests\n"; echo " ├─ Usage: " . number_format($rateLimit->getUsagePercentage(), 1) . "%\n"; - + if ($rateLimit->isExhausted()) { echo " └─ ⚠️ EXHAUSTED - Resets at " . $rateLimit->getResetDateTime()->format('Y-m-d H:i:s T') . "\n"; } else { @@ -58,7 +58,7 @@ $minutes = floor($seconds / 60); echo " └─ ✓ Resets in " . $minutes . " minutes (" . $rateLimit->getResetDateTime()->format('Y-m-d H:i:s T') . ")\n"; } - + echo "\n"; } catch (\Exception $e) { echo " └─ ✗ Error: " . $e->getMessage() . "\n\n"; @@ -68,7 +68,7 @@ try { echo "🔐 Checking Token Scopes/Permissions...\n"; $scopes = $client->getTokenScopes(); - + if ($scopes->isEmpty()) { echo " └─ ⚠️ No scopes granted (token may be invalid)\n\n"; } else { @@ -80,14 +80,14 @@ echo " │ ├─ Write Repositories: " . ($scopes->canWriteRepositories() ? "✓ YES" : "✗ NO") . "\n"; echo " │ └─ Read Organizations: " . ($scopes->canReadOrganizations() ? "✓ YES" : "✗ NO") . "\n"; echo " │\n"; - + // List all individual scopes echo " └─ Individual Scopes:\n"; foreach ($scopes->toArray() as $scope) { echo " • " . $scope . "\n"; } } - + echo "\n"; } catch (\Exception $e) { echo " └─ ✗ Error: " . $e->getMessage() . "\n\n"; diff --git a/src/CreateInstallationAccessTokenParams.php b/src/CreateInstallationAccessTokenParams.php index bee4a07..c572ced 100644 --- a/src/CreateInstallationAccessTokenParams.php +++ b/src/CreateInstallationAccessTokenParams.php @@ -1,49 +1,49 @@ - $repositories List of repository names to grant access to (optional) - * @param array $permissions Permissions to grant (optional) - */ - public function __construct( - public readonly array $repositories = [], - public readonly array $permissions = [] - ) {} - - /** - * Convert to array for API request - * - * @return array - */ - public function toArray(): array - { - $data = []; - - if ($this->repositories !== []) { - $data['repositories'] = $this->repositories; - } - - if ($this->permissions !== []) { - $data['permissions'] = $this->permissions; - } - - return $data; - } -} + $repositories List of repository names to grant access to (optional) + * @param array $permissions Permissions to grant (optional) + */ + public function __construct( + public readonly array $repositories = [], + public readonly array $permissions = [] + ) {} + + /** + * Convert to array for API request + * + * @return array + */ + public function toArray(): array + { + $data = []; + + if ($this->repositories !== []) { + $data['repositories'] = $this->repositories; + } + + if ($this->permissions !== []) { + $data['permissions'] = $this->permissions; + } + + return $data; + } +} diff --git a/src/CreateInstallationAccessTokenRequestFactory.php b/src/CreateInstallationAccessTokenRequestFactory.php index 032f195..8bb4a2a 100644 --- a/src/CreateInstallationAccessTokenRequestFactory.php +++ b/src/CreateInstallationAccessTokenRequestFactory.php @@ -1,58 +1,58 @@ -config->endpoint, - $this->installationId - ); - - $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); - $stream = $this->streamFactory->createStream($jsonBody); - - $request = $this->requestFactory->createRequest('POST', $url); - $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); - $request = $request->withHeader('Accept', 'application/vnd.github+json'); - $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); - $request = $request->withHeader('Content-Type', 'application/json'); - $request = $request->withBody($stream); - - return $request; - } -} +config->endpoint, + $this->installationId + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/GetAuthenticatedAppRequestFactory.php b/src/GetAuthenticatedAppRequestFactory.php index 02b77ef..4a200d7 100644 --- a/src/GetAuthenticatedAppRequestFactory.php +++ b/src/GetAuthenticatedAppRequestFactory.php @@ -1,45 +1,45 @@ -config->endpoint); - - $request = $this->requestFactory->createRequest('GET', $url); - $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); - $request = $request->withHeader('Accept', 'application/vnd.github+json'); - $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); - - return $request; - } -} +config->endpoint); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + + return $request; + } +} diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index ebd2ab1..e4c5914 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -855,8 +855,7 @@ public function getCurrentUser(): GithubUser public function createInstallationAccessToken( int $installationId, CreateInstallationAccessTokenParams $params = new CreateInstallationAccessTokenParams() - ): InstallationAccessToken - { + ): InstallationAccessToken { if ($this->streamFactory === null) { throw new Exception('StreamFactory is required for createInstallationAccessToken'); } diff --git a/src/GithubApp.php b/src/GithubApp.php index 5daa5a9..0b48d08 100644 --- a/src/GithubApp.php +++ b/src/GithubApp.php @@ -1,64 +1,64 @@ -slug; - } - - /** - * Create from GitHub API response - * - * @param object $data The API response data - * @return self - */ - public static function fromApiResponse(object $data): self - { - $owner = isset($data->owner) ? GithubUser::fromApiResponse($data->owner) : new GithubUser('', 0, '', '', ''); - - return new self( - id: $data->id ?? 0, - slug: $data->slug ?? '', - name: $data->name ?? '', - owner: $owner, - createdAt: $data->created_at ?? '', - updatedAt: $data->updated_at ?? '' - ); - } -} +slug; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $owner = isset($data->owner) ? GithubUser::fromApiResponse($data->owner) : new GithubUser('', 0, '', '', ''); + + return new self( + id: $data->id ?? 0, + slug: $data->slug ?? '', + name: $data->name ?? '', + owner: $owner, + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '' + ); + } +} diff --git a/src/GithubInstallation.php b/src/GithubInstallation.php index 62af19f..93d8485 100644 --- a/src/GithubInstallation.php +++ b/src/GithubInstallation.php @@ -1,61 +1,61 @@ -id; - } - - /** - * Create from GitHub API response - * - * @param object $data The API response data - * @return self - */ - public static function fromApiResponse(object $data): self - { - $account = isset($data->account) ? GithubUser::fromApiResponse($data->account) : new GithubUser('', 0, '', '', ''); - - return new self( - id: $data->id ?? 0, - account: $account, - repositorySelection: $data->repository_selection ?? '', - createdAt: $data->created_at ?? '', - updatedAt: $data->updated_at ?? '' - ); - } -} +id; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $account = isset($data->account) ? GithubUser::fromApiResponse($data->account) : new GithubUser('', 0, '', '', ''); + + return new self( + id: $data->id ?? 0, + account: $account, + repositorySelection: $data->repository_selection ?? '', + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '' + ); + } +} diff --git a/src/GithubInstallationList.php b/src/GithubInstallationList.php index ed2efb1..e1aaba7 100644 --- a/src/GithubInstallationList.php +++ b/src/GithubInstallationList.php @@ -1,72 +1,72 @@ - $installations - */ - public function __construct( - private array $installations = [] - ) {} - - public function current(): GithubInstallation - { - return $this->installations[$this->position]; - } - - public function key(): int - { - return $this->position; - } - - public function next(): void - { - ++$this->position; - } - - public function rewind(): void - { - $this->position = 0; - } - - public function valid(): bool - { - return isset($this->installations[$this->position]); - } - - public function count(): int - { - return count($this->installations); - } - - /** - * Get all installations as array - * - * @return array - */ - public function toArray(): array - { - return $this->installations; - } -} + $installations + */ + public function __construct( + private array $installations = [] + ) {} + + public function current(): GithubInstallation + { + return $this->installations[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->installations[$this->position]); + } + + public function count(): int + { + return count($this->installations); + } + + /** + * Get all installations as array + * + * @return array + */ + public function toArray(): array + { + return $this->installations; + } +} diff --git a/src/InstallationAccessToken.php b/src/InstallationAccessToken.php index eb5e658..751adf9 100644 --- a/src/InstallationAccessToken.php +++ b/src/InstallationAccessToken.php @@ -1,49 +1,49 @@ - $permissions Granted permissions - * @param string $repositorySelection Repository selection type (all/selected) - */ - public function __construct( - public readonly string $token, - public readonly string $expiresAt, - public readonly array $permissions, - public readonly string $repositorySelection - ) {} - - /** - * Create from GitHub API response - * - * @param object $data The API response data - * @return self - */ - public static function fromApiResponse(object $data): self - { - return new self( - token: $data->token ?? '', - expiresAt: $data->expires_at ?? '', - permissions: (array) ($data->permissions ?? []), - repositorySelection: $data->repository_selection ?? '' - ); - } -} + $permissions Granted permissions + * @param string $repositorySelection Repository selection type (all/selected) + */ + public function __construct( + public readonly string $token, + public readonly string $expiresAt, + public readonly array $permissions, + public readonly string $repositorySelection + ) {} + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + token: $data->token ?? '', + expiresAt: $data->expires_at ?? '', + permissions: (array) ($data->permissions ?? []), + repositorySelection: $data->repository_selection ?? '' + ); + } +} diff --git a/src/ListInstallationsRequestFactory.php b/src/ListInstallationsRequestFactory.php index 2b5689a..302974a 100644 --- a/src/ListInstallationsRequestFactory.php +++ b/src/ListInstallationsRequestFactory.php @@ -1,45 +1,45 @@ -config->endpoint); - - $request = $this->requestFactory->createRequest('GET', $url); - $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); - $request = $request->withHeader('Accept', 'application/vnd.github+json'); - $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); - - return $request; - } -} +config->endpoint); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + + return $request; + } +} diff --git a/test/unit/CreateInstallationAccessTokenParamsTest.php b/test/unit/CreateInstallationAccessTokenParamsTest.php index f334d9e..7212a37 100644 --- a/test/unit/CreateInstallationAccessTokenParamsTest.php +++ b/test/unit/CreateInstallationAccessTokenParamsTest.php @@ -1,116 +1,116 @@ -assertSame([], $params->repositories); - $this->assertSame([], $params->permissions); - } - - public function testConstructorWithRepositoriesOnly(): void - { - $params = new CreateInstallationAccessTokenParams( - repositories: ['repo1', 'repo2'] - ); - - $this->assertSame(['repo1', 'repo2'], $params->repositories); - $this->assertSame([], $params->permissions); - } - - public function testConstructorWithPermissionsOnly(): void - { - $params = new CreateInstallationAccessTokenParams( - permissions: ['contents' => 'read', 'issues' => 'write'] - ); - - $this->assertSame([], $params->repositories); - $this->assertSame(['contents' => 'read', 'issues' => 'write'], $params->permissions); - } - - public function testConstructorWithBothRepositoriesAndPermissions(): void - { - $params = new CreateInstallationAccessTokenParams( - repositories: ['my-repo'], - permissions: ['pull_requests' => 'write', 'metadata' => 'read'] - ); - - $this->assertSame(['my-repo'], $params->repositories); - $this->assertSame(['pull_requests' => 'write', 'metadata' => 'read'], $params->permissions); - } - - public function testToArrayExcludesEmptyArrays(): void - { - $params = new CreateInstallationAccessTokenParams(); - - $array = $params->toArray(); - - $this->assertSame([], $array); - $this->assertArrayNotHasKey('repositories', $array); - $this->assertArrayNotHasKey('permissions', $array); - } - - public function testToArrayIncludesNonEmptyRepositories(): void - { - $params = new CreateInstallationAccessTokenParams( - repositories: ['repo1', 'repo2', 'repo3'] - ); - - $array = $params->toArray(); - - $this->assertArrayHasKey('repositories', $array); - $this->assertSame(['repo1', 'repo2', 'repo3'], $array['repositories']); - $this->assertArrayNotHasKey('permissions', $array); - } - - public function testToArrayIncludesNonEmptyPermissions(): void - { - $params = new CreateInstallationAccessTokenParams( - permissions: ['contents' => 'write', 'issues' => 'read'] - ); - - $array = $params->toArray(); - - $this->assertArrayNotHasKey('repositories', $array); - $this->assertArrayHasKey('permissions', $array); - $this->assertSame(['contents' => 'write', 'issues' => 'read'], $array['permissions']); - } - - public function testToArrayIncludesBothWhenNonEmpty(): void - { - $params = new CreateInstallationAccessTokenParams( - repositories: ['test-repo'], - permissions: ['metadata' => 'read'] - ); - - $array = $params->toArray(); - - $this->assertSame([ - 'repositories' => ['test-repo'], - 'permissions' => ['metadata' => 'read'] - ], $array); - } -} +assertSame([], $params->repositories); + $this->assertSame([], $params->permissions); + } + + public function testConstructorWithRepositoriesOnly(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2'] + ); + + $this->assertSame(['repo1', 'repo2'], $params->repositories); + $this->assertSame([], $params->permissions); + } + + public function testConstructorWithPermissionsOnly(): void + { + $params = new CreateInstallationAccessTokenParams( + permissions: ['contents' => 'read', 'issues' => 'write'] + ); + + $this->assertSame([], $params->repositories); + $this->assertSame(['contents' => 'read', 'issues' => 'write'], $params->permissions); + } + + public function testConstructorWithBothRepositoriesAndPermissions(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['my-repo'], + permissions: ['pull_requests' => 'write', 'metadata' => 'read'] + ); + + $this->assertSame(['my-repo'], $params->repositories); + $this->assertSame(['pull_requests' => 'write', 'metadata' => 'read'], $params->permissions); + } + + public function testToArrayExcludesEmptyArrays(): void + { + $params = new CreateInstallationAccessTokenParams(); + + $array = $params->toArray(); + + $this->assertSame([], $array); + $this->assertArrayNotHasKey('repositories', $array); + $this->assertArrayNotHasKey('permissions', $array); + } + + public function testToArrayIncludesNonEmptyRepositories(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2', 'repo3'] + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('repositories', $array); + $this->assertSame(['repo1', 'repo2', 'repo3'], $array['repositories']); + $this->assertArrayNotHasKey('permissions', $array); + } + + public function testToArrayIncludesNonEmptyPermissions(): void + { + $params = new CreateInstallationAccessTokenParams( + permissions: ['contents' => 'write', 'issues' => 'read'] + ); + + $array = $params->toArray(); + + $this->assertArrayNotHasKey('repositories', $array); + $this->assertArrayHasKey('permissions', $array); + $this->assertSame(['contents' => 'write', 'issues' => 'read'], $array['permissions']); + } + + public function testToArrayIncludesBothWhenNonEmpty(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['test-repo'], + permissions: ['metadata' => 'read'] + ); + + $array = $params->toArray(); + + $this->assertSame([ + 'repositories' => ['test-repo'], + 'permissions' => ['metadata' => 'read'], + ], $array); + } +} diff --git a/test/unit/CreatePullRequestParamsTest.php b/test/unit/CreatePullRequestParamsTest.php index da0f751..cbcc472 100644 --- a/test/unit/CreatePullRequestParamsTest.php +++ b/test/unit/CreatePullRequestParamsTest.php @@ -62,7 +62,7 @@ public function testToArrayWithRequiredParametersOnly(): void 'title' => 'Test PR', 'head' => 'test', 'base' => 'main', - 'maintainer_can_modify' => true + 'maintainer_can_modify' => true, ], $array); } @@ -85,7 +85,7 @@ public function testToArrayWithAllParameters(): void 'base' => 'main', 'maintainer_can_modify' => false, 'body' => 'Test description', - 'draft' => true + 'draft' => true, ], $array); } diff --git a/test/unit/GithubApiClientAppAuthTest.php b/test/unit/GithubApiClientAppAuthTest.php index bebbe6c..78c6486 100644 --- a/test/unit/GithubApiClientAppAuthTest.php +++ b/test/unit/GithubApiClientAppAuthTest.php @@ -1,437 +1,437 @@ -createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt-token'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - $bodyStream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - $request->method('withBody')->willReturnSelf(); - $streamFactory->method('createStream')->willReturn($bodyStream); - - $responseBody = json_encode([ - 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', - 'expires_at' => '2026-03-02T13:00:00Z', - 'permissions' => ['contents' => 'read', 'metadata' => 'read'], - 'repository_selection' => 'all' - ]); - - $stream->method('__toString')->willReturn($responseBody); - $response->method('getStatusCode')->willReturn(201); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - $token = $client->createInstallationAccessToken(12345); - - $this->assertInstanceOf(InstallationAccessToken::class, $token); - $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); - $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); - $this->assertSame('all', $token->repositorySelection); - } - - public function testCreateInstallationAccessTokenWithOptionalParameters(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt-token'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - $bodyStream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - $request->method('withBody')->willReturnSelf(); - $streamFactory->method('createStream')->willReturn($bodyStream); - - $responseBody = json_encode([ - 'token' => 'ghs_specific_repos', - 'expires_at' => '2026-03-02T14:00:00Z', - 'permissions' => ['contents' => 'write', 'issues' => 'write'], - 'repository_selection' => 'selected' - ]); - - $stream->method('__toString')->willReturn($responseBody); - $response->method('getStatusCode')->willReturn(201); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $params = new CreateInstallationAccessTokenParams( - repositories: ['repo1', 'repo2'], - permissions: ['contents' => 'write', 'issues' => 'write'] - ); - - $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - $token = $client->createInstallationAccessToken(67890, $params); - - $this->assertInstanceOf(InstallationAccessToken::class, $token); - $this->assertSame('ghs_specific_repos', $token->token); - $this->assertSame('selected', $token->repositorySelection); - } - - public function testCreateInstallationAccessTokenRequiresStreamFactory(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt-token'); - - $client = new GithubApiClient($httpClient, $requestFactory, $config, null); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('StreamFactory is required for createInstallationAccessToken'); - - $client->createInstallationAccessToken(12345); - } - - public function testCreateInstallationAccessTokenThrowsOn401Unauthorized(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'invalid-jwt'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - $bodyStream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - $request->method('withBody')->willReturnSelf(); - $streamFactory->method('createStream')->willReturn($bodyStream); - - $errorBody = json_encode(['message' => 'Bad credentials']); - $stream->method('__toString')->willReturn($errorBody); - $response->method('getStatusCode')->willReturn(401); - $response->method('getReasonPhrase')->willReturn('Unauthorized'); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('401 Unauthorized'); - - $client->createInstallationAccessToken(12345); - } - - public function testCreateInstallationAccessTokenThrowsOn403Forbidden(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - $bodyStream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - $request->method('withBody')->willReturnSelf(); - $streamFactory->method('createStream')->willReturn($bodyStream); - - $errorBody = json_encode(['message' => 'Forbidden']); - $stream->method('__toString')->willReturn($errorBody); - $response->method('getStatusCode')->willReturn(403); - $response->method('getReasonPhrase')->willReturn('Forbidden'); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('403 Forbidden'); - - $client->createInstallationAccessToken(12345); - } - - public function testCreateInstallationAccessTokenThrowsOn404NotFound(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - $bodyStream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - $request->method('withBody')->willReturnSelf(); - $streamFactory->method('createStream')->willReturn($bodyStream); - - $errorBody = json_encode(['message' => 'Not Found']); - $stream->method('__toString')->willReturn($errorBody); - $response->method('getStatusCode')->willReturn(404); - $response->method('getReasonPhrase')->willReturn('Not Found'); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('404 Not Found'); - - $client->createInstallationAccessToken(99999); - } - - // Tests for listInstallations - - public function testListInstallationsSuccess(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt-token'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - - $responseBody = json_encode([ - [ - 'id' => 111, - 'account' => ['login' => 'org1', 'id' => 1, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], - 'repository_selection' => 'all', - 'created_at' => '2026-01-01T00:00:00Z', - 'updated_at' => '2026-01-01T00:00:00Z' - ], - [ - 'id' => 222, - 'account' => ['login' => 'org2', 'id' => 2, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], - 'repository_selection' => 'selected', - 'created_at' => '2026-01-02T00:00:00Z', - 'updated_at' => '2026-01-02T00:00:00Z' - ] - ]); - - $stream->method('__toString')->willReturn($responseBody); - $response->method('getStatusCode')->willReturn(200); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config); - $installations = $client->listInstallations(); - - $this->assertInstanceOf(GithubInstallationList::class, $installations); - $this->assertCount(2, $installations); - - $installationArray = $installations->toArray(); - $this->assertSame(111, $installationArray[0]->id); - $this->assertSame('org1', $installationArray[0]->account->login); - $this->assertSame(222, $installationArray[1]->id); - $this->assertSame('org2', $installationArray[1]->account->login); - } - - public function testListInstallationsEmptyArray(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt-token'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - - $stream->method('__toString')->willReturn('[]'); - $response->method('getStatusCode')->willReturn(200); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config); - $installations = $client->listInstallations(); - - $this->assertInstanceOf(GithubInstallationList::class, $installations); - $this->assertCount(0, $installations); - } - - public function testListInstallationsMultipleInstallations(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt-token'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - - $installationsData = []; - for ($i = 1; $i <= 5; $i++) { - $installationsData[] = [ - 'id' => $i * 100, - 'account' => ['login' => "org{$i}", 'id' => $i, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], - 'repository_selection' => 'all', - 'created_at' => '2026-01-01T00:00:00Z', - 'updated_at' => '2026-01-01T00:00:00Z' - ]; - } - - $stream->method('__toString')->willReturn(json_encode($installationsData)); - $response->method('getStatusCode')->willReturn(200); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config); - $installations = $client->listInstallations(); - - $this->assertCount(5, $installations); - } - - public function testListInstallationsErrorHandling(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'invalid-jwt'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - - $stream->method('__toString')->willReturn(json_encode(['message' => 'Unauthorized'])); - $response->method('getStatusCode')->willReturn(401); - $response->method('getReasonPhrase')->willReturn('Unauthorized'); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('401 Unauthorized'); - - $client->listInstallations(); - } - - // Tests for getAuthenticatedApp - - public function testGetAuthenticatedAppSuccess(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'test-jwt-token'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - - $responseBody = json_encode([ - 'id' => 123456, - 'slug' => 'my-github-app', - 'name' => 'My GitHub App', - 'owner' => [ - 'login' => 'app-owner', - 'id' => 789, - 'avatar_url' => 'https://avatars.githubusercontent.com/u/789', - 'html_url' => 'https://github.com/app-owner', - 'type' => 'User' - ], - 'created_at' => '2025-01-01T00:00:00Z', - 'updated_at' => '2026-03-01T00:00:00Z' - ]); - - $stream->method('__toString')->willReturn($responseBody); - $response->method('getStatusCode')->willReturn(200); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config); - $app = $client->getAuthenticatedApp(); - - $this->assertInstanceOf(GithubApp::class, $app); - $this->assertSame(123456, $app->id); - $this->assertSame('my-github-app', $app->slug); - $this->assertSame('My GitHub App', $app->name); - $this->assertSame('app-owner', $app->owner->login); - } - - public function testGetAuthenticatedAppErrorHandling(): void - { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $config = new GithubApiConfig(jwt: 'invalid-jwt'); - - $request = $this->createMock(RequestInterface::class); - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(StreamInterface::class); - - $requestFactory->method('createRequest')->willReturn($request); - $request->method('withHeader')->willReturnSelf(); - - $stream->method('__toString')->willReturn(json_encode(['message' => 'Bad credentials'])); - $response->method('getStatusCode')->willReturn(401); - $response->method('getReasonPhrase')->willReturn('Unauthorized'); - $response->method('getBody')->willReturn($stream); - $httpClient->method('sendRequest')->willReturn($response); - - $client = new GithubApiClient($httpClient, $requestFactory, $config); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('401 Unauthorized'); - - $client->getAuthenticatedApp(); - } -} +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $responseBody = json_encode([ + 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', + 'expires_at' => '2026-03-02T13:00:00Z', + 'permissions' => ['contents' => 'read', 'metadata' => 'read'], + 'repository_selection' => 'all', + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $token = $client->createInstallationAccessToken(12345); + + $this->assertInstanceOf(InstallationAccessToken::class, $token); + $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); + $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); + $this->assertSame('all', $token->repositorySelection); + } + + public function testCreateInstallationAccessTokenWithOptionalParameters(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $responseBody = json_encode([ + 'token' => 'ghs_specific_repos', + 'expires_at' => '2026-03-02T14:00:00Z', + 'permissions' => ['contents' => 'write', 'issues' => 'write'], + 'repository_selection' => 'selected', + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2'], + permissions: ['contents' => 'write', 'issues' => 'write'] + ); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $token = $client->createInstallationAccessToken(67890, $params); + + $this->assertInstanceOf(InstallationAccessToken::class, $token); + $this->assertSame('ghs_specific_repos', $token->token); + $this->assertSame('selected', $token->repositorySelection); + } + + public function testCreateInstallationAccessTokenRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, null); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createInstallationAccessToken'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn401Unauthorized(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Bad credentials']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn403Forbidden(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Forbidden']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(403); + $response->method('getReasonPhrase')->willReturn('Forbidden'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('403 Forbidden'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn404NotFound(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Not Found']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->createInstallationAccessToken(99999); + } + + // Tests for listInstallations + + public function testListInstallationsSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBody = json_encode([ + [ + 'id' => 111, + 'account' => ['login' => 'org1', 'id' => 1, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z', + ], + [ + 'id' => 222, + 'account' => ['login' => 'org2', 'id' => 2, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'selected', + 'created_at' => '2026-01-02T00:00:00Z', + 'updated_at' => '2026-01-02T00:00:00Z', + ], + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertInstanceOf(GithubInstallationList::class, $installations); + $this->assertCount(2, $installations); + + $installationArray = $installations->toArray(); + $this->assertSame(111, $installationArray[0]->id); + $this->assertSame('org1', $installationArray[0]->account->login); + $this->assertSame(222, $installationArray[1]->id); + $this->assertSame('org2', $installationArray[1]->account->login); + } + + public function testListInstallationsEmptyArray(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn('[]'); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertInstanceOf(GithubInstallationList::class, $installations); + $this->assertCount(0, $installations); + } + + public function testListInstallationsMultipleInstallations(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $installationsData = []; + for ($i = 1; $i <= 5; $i++) { + $installationsData[] = [ + 'id' => $i * 100, + 'account' => ['login' => "org{$i}", 'id' => $i, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z', + ]; + } + + $stream->method('__toString')->willReturn(json_encode($installationsData)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertCount(5, $installations); + } + + public function testListInstallationsErrorHandling(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn(json_encode(['message' => 'Unauthorized'])); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->listInstallations(); + } + + // Tests for getAuthenticatedApp + + public function testGetAuthenticatedAppSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBody = json_encode([ + 'id' => 123456, + 'slug' => 'my-github-app', + 'name' => 'My GitHub App', + 'owner' => [ + 'login' => 'app-owner', + 'id' => 789, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/789', + 'html_url' => 'https://github.com/app-owner', + 'type' => 'User', + ], + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2026-03-01T00:00:00Z', + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $app = $client->getAuthenticatedApp(); + + $this->assertInstanceOf(GithubApp::class, $app); + $this->assertSame(123456, $app->id); + $this->assertSame('my-github-app', $app->slug); + $this->assertSame('My GitHub App', $app->name); + $this->assertSame('app-owner', $app->owner->login); + } + + public function testGetAuthenticatedAppErrorHandling(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn(json_encode(['message' => 'Bad credentials'])); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->getAuthenticatedApp(); + } +} diff --git a/test/unit/GithubApiClientErrorHandlingTest.php b/test/unit/GithubApiClientErrorHandlingTest.php index 68ecb9f..91e63b7 100644 --- a/test/unit/GithubApiClientErrorHandlingTest.php +++ b/test/unit/GithubApiClientErrorHandlingTest.php @@ -195,7 +195,7 @@ public function testCreateReviewThrowsDetailedErrorOn422(): void $errorBody = json_encode([ 'message' => 'Unprocessable Entity', 'errors' => ['Review Can not approve your own pull request'], - 'documentation_url' => 'https://docs.github.com/rest/pulls/reviews#create-a-review-for-a-pull-request' + 'documentation_url' => 'https://docs.github.com/rest/pulls/reviews#create-a-review-for-a-pull-request', ]); $errorStream = $this->createMock(StreamInterface::class); $errorStream->method('__toString')->willReturn($errorBody); diff --git a/test/unit/GithubApiConfigTest.php b/test/unit/GithubApiConfigTest.php index db09678..06f468a 100644 --- a/test/unit/GithubApiConfigTest.php +++ b/test/unit/GithubApiConfigTest.php @@ -1,95 +1,95 @@ -assertSame('https://api.github.com', $config->endpoint); - $this->assertSame('', $config->accessToken); - $this->assertSame('2022-11-28', $config->apiVersion); - $this->assertSame('', $config->jwt); - } - - public function testWithAccessTokenOnly(): void - { - $config = new GithubApiConfig(accessToken: 'ghp_test123'); - - $this->assertSame('https://api.github.com', $config->endpoint); - $this->assertSame('ghp_test123', $config->accessToken); - $this->assertSame('2022-11-28', $config->apiVersion); - $this->assertSame('', $config->jwt); - } - - public function testWithJwtOnly(): void - { - $config = new GithubApiConfig(jwt: 'eyJhbGc.eyJpc3M.signature'); - - $this->assertSame('https://api.github.com', $config->endpoint); - $this->assertSame('', $config->accessToken); - $this->assertSame('2022-11-28', $config->apiVersion); - $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); - } - - public function testWithBothAccessTokenAndJwt(): void - { - $config = new GithubApiConfig( - accessToken: 'ghp_test123', - jwt: 'eyJhbGc.eyJpc3M.signature' - ); - - $this->assertSame('https://api.github.com', $config->endpoint); - $this->assertSame('ghp_test123', $config->accessToken); - $this->assertSame('2022-11-28', $config->apiVersion); - $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); - } - - public function testWithCustomEndpointAndApiVersion(): void - { - $config = new GithubApiConfig( - endpoint: 'https://github.example.com/api/v3', - accessToken: 'token123', - apiVersion: '2023-01-01', - jwt: 'jwt123' - ); - - $this->assertSame('https://github.example.com/api/v3', $config->endpoint); - $this->assertSame('token123', $config->accessToken); - $this->assertSame('2023-01-01', $config->apiVersion); - $this->assertSame('jwt123', $config->jwt); - } - - public function testPropertiesAreReadonly(): void - { - $config = new GithubApiConfig(); - - $reflection = new \ReflectionClass($config); - $properties = $reflection->getProperties(); - - foreach ($properties as $property) { - $this->assertTrue($property->isReadOnly(), "Property {$property->getName()} should be readonly"); - } - } -} +assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('', $config->jwt); + } + + public function testWithAccessTokenOnly(): void + { + $config = new GithubApiConfig(accessToken: 'ghp_test123'); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('ghp_test123', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('', $config->jwt); + } + + public function testWithJwtOnly(): void + { + $config = new GithubApiConfig(jwt: 'eyJhbGc.eyJpc3M.signature'); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); + } + + public function testWithBothAccessTokenAndJwt(): void + { + $config = new GithubApiConfig( + accessToken: 'ghp_test123', + jwt: 'eyJhbGc.eyJpc3M.signature' + ); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('ghp_test123', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); + } + + public function testWithCustomEndpointAndApiVersion(): void + { + $config = new GithubApiConfig( + endpoint: 'https://github.example.com/api/v3', + accessToken: 'token123', + apiVersion: '2023-01-01', + jwt: 'jwt123' + ); + + $this->assertSame('https://github.example.com/api/v3', $config->endpoint); + $this->assertSame('token123', $config->accessToken); + $this->assertSame('2023-01-01', $config->apiVersion); + $this->assertSame('jwt123', $config->jwt); + } + + public function testPropertiesAreReadonly(): void + { + $config = new GithubApiConfig(); + + $reflection = new \ReflectionClass($config); + $properties = $reflection->getProperties(); + + foreach ($properties as $property) { + $this->assertTrue($property->isReadOnly(), "Property {$property->getName()} should be readonly"); + } + } +} diff --git a/test/unit/GithubAppTest.php b/test/unit/GithubAppTest.php index bfac31e..1618001 100644 --- a/test/unit/GithubAppTest.php +++ b/test/unit/GithubAppTest.php @@ -1,138 +1,138 @@ -assertSame(456, $app->id); - $this->assertSame('my-awesome-app', $app->slug); - $this->assertSame('My Awesome App', $app->name); - $this->assertSame($owner, $app->owner); - $this->assertSame('2025-01-15T10:00:00Z', $app->createdAt); - $this->assertSame('2026-02-20T15:30:00Z', $app->updatedAt); - } - - public function testFromApiResponseWithCompleteData(): void - { - $data = json_decode(json_encode([ - 'id' => 789, - 'slug' => 'ci-bot', - 'name' => 'CI Bot', - 'owner' => [ - 'login' => 'org-name', - 'id' => 999, - 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', - 'html_url' => 'https://github.com/org-name', - 'type' => 'Organization' - ], - 'created_at' => '2024-06-01T00:00:00Z', - 'updated_at' => '2026-03-01T12:00:00Z' - ])); - - $app = GithubApp::fromApiResponse($data); - - $this->assertSame(789, $app->id); - $this->assertSame('ci-bot', $app->slug); - $this->assertSame('CI Bot', $app->name); - $this->assertSame('org-name', $app->owner->login); - $this->assertSame(999, $app->owner->id); - $this->assertSame('Organization', $app->owner->type); - $this->assertSame('2024-06-01T00:00:00Z', $app->createdAt); - $this->assertSame('2026-03-01T12:00:00Z', $app->updatedAt); - } - - public function testFromApiResponseWithMinimalData(): void - { - $data = json_decode(json_encode([ - 'id' => 1 - ])); - - $app = GithubApp::fromApiResponse($data); - - $this->assertSame(1, $app->id); - $this->assertSame('', $app->slug); - $this->assertSame('', $app->name); - $this->assertSame('', $app->owner->login); - $this->assertSame(0, $app->owner->id); - $this->assertSame('', $app->createdAt); - $this->assertSame('', $app->updatedAt); - } - - public function testToStringReturnsSlug(): void - { - $owner = new GithubUser('test', 1, '', '', ''); - $app = new GithubApp( - id: 100, - slug: 'test-app-slug', - name: 'Test App', - owner: $owner, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z' - ); - - $this->assertSame('test-app-slug', (string) $app); - $this->assertSame('test-app-slug', $app->__toString()); - } - - public function testOwnerGithubUserParsing(): void - { - $data = json_decode(json_encode([ - 'id' => 222, - 'slug' => 'automation-app', - 'name' => 'Automation App', - 'owner' => [ - 'login' => 'bot-user', - 'id' => 333, - 'avatar_url' => 'https://avatars.githubusercontent.com/u/333', - 'html_url' => 'https://github.com/bot-user', - 'type' => 'Bot' - ], - 'created_at' => '2025-12-01T00:00:00Z', - 'updated_at' => '2026-01-15T00:00:00Z' - ])); - - $app = GithubApp::fromApiResponse($data); - - $this->assertInstanceOf(GithubUser::class, $app->owner); - $this->assertSame('bot-user', $app->owner->login); - $this->assertSame(333, $app->owner->id); - $this->assertSame('Bot', $app->owner->type); - } -} +assertSame(456, $app->id); + $this->assertSame('my-awesome-app', $app->slug); + $this->assertSame('My Awesome App', $app->name); + $this->assertSame($owner, $app->owner); + $this->assertSame('2025-01-15T10:00:00Z', $app->createdAt); + $this->assertSame('2026-02-20T15:30:00Z', $app->updatedAt); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'id' => 789, + 'slug' => 'ci-bot', + 'name' => 'CI Bot', + 'owner' => [ + 'login' => 'org-name', + 'id' => 999, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', + 'html_url' => 'https://github.com/org-name', + 'type' => 'Organization', + ], + 'created_at' => '2024-06-01T00:00:00Z', + 'updated_at' => '2026-03-01T12:00:00Z', + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertSame(789, $app->id); + $this->assertSame('ci-bot', $app->slug); + $this->assertSame('CI Bot', $app->name); + $this->assertSame('org-name', $app->owner->login); + $this->assertSame(999, $app->owner->id); + $this->assertSame('Organization', $app->owner->type); + $this->assertSame('2024-06-01T00:00:00Z', $app->createdAt); + $this->assertSame('2026-03-01T12:00:00Z', $app->updatedAt); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'id' => 1, + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertSame(1, $app->id); + $this->assertSame('', $app->slug); + $this->assertSame('', $app->name); + $this->assertSame('', $app->owner->login); + $this->assertSame(0, $app->owner->id); + $this->assertSame('', $app->createdAt); + $this->assertSame('', $app->updatedAt); + } + + public function testToStringReturnsSlug(): void + { + $owner = new GithubUser('test', 1, '', '', ''); + $app = new GithubApp( + id: 100, + slug: 'test-app-slug', + name: 'Test App', + owner: $owner, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + ); + + $this->assertSame('test-app-slug', (string) $app); + $this->assertSame('test-app-slug', $app->__toString()); + } + + public function testOwnerGithubUserParsing(): void + { + $data = json_decode(json_encode([ + 'id' => 222, + 'slug' => 'automation-app', + 'name' => 'Automation App', + 'owner' => [ + 'login' => 'bot-user', + 'id' => 333, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/333', + 'html_url' => 'https://github.com/bot-user', + 'type' => 'Bot', + ], + 'created_at' => '2025-12-01T00:00:00Z', + 'updated_at' => '2026-01-15T00:00:00Z', + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertInstanceOf(GithubUser::class, $app->owner); + $this->assertSame('bot-user', $app->owner->login); + $this->assertSame(333, $app->owner->id); + $this->assertSame('Bot', $app->owner->type); + } +} diff --git a/test/unit/GithubCheckRunTest.php b/test/unit/GithubCheckRunTest.php index d34cb11..302639f 100644 --- a/test/unit/GithubCheckRunTest.php +++ b/test/unit/GithubCheckRunTest.php @@ -66,7 +66,7 @@ public function testFromApiResponseWithCompleteData(): void 'html_url' => 'https://github.com/org/repo/runs/999888', 'details_url' => 'https://github.com/org/repo/runs/999888/details', 'started_at' => '2026-02-26T11:00:00Z', - 'completed_at' => null + 'completed_at' => null, ]; $checkRun = GithubCheckRun::fromApiResponse($data); diff --git a/test/unit/GithubCommentTest.php b/test/unit/GithubCommentTest.php index bb81858..aada5b6 100644 --- a/test/unit/GithubCommentTest.php +++ b/test/unit/GithubCommentTest.php @@ -74,12 +74,12 @@ public function testFromApiResponseWithCompleteData(): void 'id' => 777, 'avatar_url' => 'https://avatar.example.com/api.png', 'html_url' => 'https://github.com/apiuser', - 'type' => 'User' + 'type' => 'User', ], 'created_at' => '2026-01-15T08:30:00Z', 'updated_at' => '2026-01-15T09:45:00Z', 'html_url' => 'https://github.com/org/repo/pull/5#issuecomment-999888', - 'url' => 'https://api.github.com/repos/org/repo/issues/comments/999888' + 'url' => 'https://api.github.com/repos/org/repo/issues/comments/999888', ]; $comment = GithubComment::fromApiResponse($data); @@ -96,7 +96,7 @@ public function testFromApiResponseWithCompleteData(): void public function testFromApiResponseWithMinimalData(): void { $data = (object) [ - 'user' => (object) [] + 'user' => (object) [], ]; $comment = GithubComment::fromApiResponse($data); diff --git a/test/unit/GithubInstallationListTest.php b/test/unit/GithubInstallationListTest.php index f0134b0..6e58df4 100644 --- a/test/unit/GithubInstallationListTest.php +++ b/test/unit/GithubInstallationListTest.php @@ -1,135 +1,135 @@ -assertCount(0, $list); - $this->assertSame([], $list->toArray()); - } - - public function testIterationWithForeach(): void - { - $account1 = new GithubUser('user1', 1, '', '', ''); - $account2 = new GithubUser('user2', 2, '', '', ''); - - $installation1 = new GithubInstallation(100, $account1, 'all', '2026-01-01', '2026-01-01'); - $installation2 = new GithubInstallation(200, $account2, 'selected', '2026-01-02', '2026-01-02'); - - $list = new GithubInstallationList([$installation1, $installation2]); - - $iterations = 0; - $ids = []; - - foreach ($list as $installation) { - $iterations++; - $ids[] = $installation->id; - } - - $this->assertSame(2, $iterations); - $this->assertSame([100, 200], $ids); - } - - public function testCountMethod(): void - { - $account = new GithubUser('test', 1, '', '', ''); - - $installation1 = new GithubInstallation(1, $account, 'all', '', ''); - $installation2 = new GithubInstallation(2, $account, 'all', '', ''); - $installation3 = new GithubInstallation(3, $account, 'all', '', ''); - - $list = new GithubInstallationList([$installation1, $installation2, $installation3]); - - $this->assertCount(3, $list); - $this->assertSame(3, $list->count()); - } - - public function testToArrayMethod(): void - { - $account = new GithubUser('test', 1, '', '', ''); - - $installation1 = new GithubInstallation(111, $account, 'all', '', ''); - $installation2 = new GithubInstallation(222, $account, 'selected', '', ''); - - $list = new GithubInstallationList([$installation1, $installation2]); - $array = $list->toArray(); - - $this->assertIsArray($array); - $this->assertCount(2, $array); - $this->assertSame($installation1, $array[0]); - $this->assertSame($installation2, $array[1]); - } - - public function testMultipleInstallations(): void - { - $installations = []; - for ($i = 1; $i <= 5; $i++) { - $account = new GithubUser("user{$i}", $i, '', '', ''); - $installations[] = new GithubInstallation($i * 100, $account, 'all', '', ''); - } - - $list = new GithubInstallationList($installations); - - $this->assertCount(5, $list); - - $index = 0; - foreach ($list as $key => $installation) { - $this->assertSame($index, $key); - $this->assertSame(($index + 1) * 100, $installation->id); - $index++; - } - } - - public function testIteratorPositionTracking(): void - { - $account = new GithubUser('test', 1, '', '', ''); - $installation1 = new GithubInstallation(1, $account, 'all', '', ''); - $installation2 = new GithubInstallation(2, $account, 'all', '', ''); - - $list = new GithubInstallationList([$installation1, $installation2]); - - // First iteration - $list->rewind(); - $this->assertTrue($list->valid()); - $this->assertSame(0, $list->key()); - $this->assertSame($installation1, $list->current()); - - // Move to next - $list->next(); - $this->assertTrue($list->valid()); - $this->assertSame(1, $list->key()); - $this->assertSame($installation2, $list->current()); - - // Move past end - $list->next(); - $this->assertFalse($list->valid()); - - // Rewind and iterate again - $list->rewind(); - $this->assertTrue($list->valid()); - $this->assertSame(0, $list->key()); - $this->assertSame($installation1, $list->current()); - } -} +assertCount(0, $list); + $this->assertSame([], $list->toArray()); + } + + public function testIterationWithForeach(): void + { + $account1 = new GithubUser('user1', 1, '', '', ''); + $account2 = new GithubUser('user2', 2, '', '', ''); + + $installation1 = new GithubInstallation(100, $account1, 'all', '2026-01-01', '2026-01-01'); + $installation2 = new GithubInstallation(200, $account2, 'selected', '2026-01-02', '2026-01-02'); + + $list = new GithubInstallationList([$installation1, $installation2]); + + $iterations = 0; + $ids = []; + + foreach ($list as $installation) { + $iterations++; + $ids[] = $installation->id; + } + + $this->assertSame(2, $iterations); + $this->assertSame([100, 200], $ids); + } + + public function testCountMethod(): void + { + $account = new GithubUser('test', 1, '', '', ''); + + $installation1 = new GithubInstallation(1, $account, 'all', '', ''); + $installation2 = new GithubInstallation(2, $account, 'all', '', ''); + $installation3 = new GithubInstallation(3, $account, 'all', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2, $installation3]); + + $this->assertCount(3, $list); + $this->assertSame(3, $list->count()); + } + + public function testToArrayMethod(): void + { + $account = new GithubUser('test', 1, '', '', ''); + + $installation1 = new GithubInstallation(111, $account, 'all', '', ''); + $installation2 = new GithubInstallation(222, $account, 'selected', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2]); + $array = $list->toArray(); + + $this->assertIsArray($array); + $this->assertCount(2, $array); + $this->assertSame($installation1, $array[0]); + $this->assertSame($installation2, $array[1]); + } + + public function testMultipleInstallations(): void + { + $installations = []; + for ($i = 1; $i <= 5; $i++) { + $account = new GithubUser("user{$i}", $i, '', '', ''); + $installations[] = new GithubInstallation($i * 100, $account, 'all', '', ''); + } + + $list = new GithubInstallationList($installations); + + $this->assertCount(5, $list); + + $index = 0; + foreach ($list as $key => $installation) { + $this->assertSame($index, $key); + $this->assertSame(($index + 1) * 100, $installation->id); + $index++; + } + } + + public function testIteratorPositionTracking(): void + { + $account = new GithubUser('test', 1, '', '', ''); + $installation1 = new GithubInstallation(1, $account, 'all', '', ''); + $installation2 = new GithubInstallation(2, $account, 'all', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2]); + + // First iteration + $list->rewind(); + $this->assertTrue($list->valid()); + $this->assertSame(0, $list->key()); + $this->assertSame($installation1, $list->current()); + + // Move to next + $list->next(); + $this->assertTrue($list->valid()); + $this->assertSame(1, $list->key()); + $this->assertSame($installation2, $list->current()); + + // Move past end + $list->next(); + $this->assertFalse($list->valid()); + + // Rewind and iterate again + $list->rewind(); + $this->assertTrue($list->valid()); + $this->assertSame(0, $list->key()); + $this->assertSame($installation1, $list->current()); + } +} diff --git a/test/unit/GithubInstallationTest.php b/test/unit/GithubInstallationTest.php index fa80e59..973cc7c 100644 --- a/test/unit/GithubInstallationTest.php +++ b/test/unit/GithubInstallationTest.php @@ -1,131 +1,131 @@ -assertSame(12345, $installation->id); - $this->assertSame($account, $installation->account); - $this->assertSame('all', $installation->repositorySelection); - $this->assertSame('2026-01-01T10:00:00Z', $installation->createdAt); - $this->assertSame('2026-03-02T12:00:00Z', $installation->updatedAt); - } - - public function testFromApiResponseWithCompleteData(): void - { - $data = json_decode(json_encode([ - 'id' => 67890, - 'account' => [ - 'login' => 'my-org', - 'id' => 999, - 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', - 'html_url' => 'https://github.com/my-org', - 'type' => 'Organization' - ], - 'repository_selection' => 'selected', - 'created_at' => '2025-06-15T08:30:00Z', - 'updated_at' => '2026-02-20T14:45:00Z' - ])); - - $installation = GithubInstallation::fromApiResponse($data); - - $this->assertSame(67890, $installation->id); - $this->assertSame('my-org', $installation->account->login); - $this->assertSame(999, $installation->account->id); - $this->assertSame('Organization', $installation->account->type); - $this->assertSame('selected', $installation->repositorySelection); - $this->assertSame('2025-06-15T08:30:00Z', $installation->createdAt); - $this->assertSame('2026-02-20T14:45:00Z', $installation->updatedAt); - } - - public function testFromApiResponseWithMinimalData(): void - { - $data = json_decode(json_encode([ - 'id' => 111 - ])); - - $installation = GithubInstallation::fromApiResponse($data); - - $this->assertSame(111, $installation->id); - $this->assertSame('', $installation->account->login); - $this->assertSame(0, $installation->account->id); - $this->assertSame('', $installation->repositorySelection); - $this->assertSame('', $installation->createdAt); - $this->assertSame('', $installation->updatedAt); - } - - public function testToStringReturnsStringOfId(): void - { - $account = new GithubUser('test', 1, '', '', ''); - $installation = new GithubInstallation( - id: 54321, - account: $account, - repositorySelection: 'all', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z' - ); - - $this->assertSame('54321', (string) $installation); - $this->assertSame('54321', $installation->__toString()); - } - - public function testAccountGithubUserParsing(): void - { - $data = json_decode(json_encode([ - 'id' => 123, - 'account' => [ - 'login' => 'bot-account', - 'id' => 777, - 'avatar_url' => 'https://avatars.githubusercontent.com/u/777', - 'html_url' => 'https://github.com/bot-account', - 'type' => 'Bot' - ], - 'repository_selection' => 'all', - 'created_at' => '2026-01-01T00:00:00Z', - 'updated_at' => '2026-02-01T00:00:00Z' - ])); - - $installation = GithubInstallation::fromApiResponse($data); - - $this->assertInstanceOf(GithubUser::class, $installation->account); - $this->assertSame('bot-account', $installation->account->login); - $this->assertSame(777, $installation->account->id); - $this->assertSame('Bot', $installation->account->type); - } -} +assertSame(12345, $installation->id); + $this->assertSame($account, $installation->account); + $this->assertSame('all', $installation->repositorySelection); + $this->assertSame('2026-01-01T10:00:00Z', $installation->createdAt); + $this->assertSame('2026-03-02T12:00:00Z', $installation->updatedAt); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'id' => 67890, + 'account' => [ + 'login' => 'my-org', + 'id' => 999, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', + 'html_url' => 'https://github.com/my-org', + 'type' => 'Organization', + ], + 'repository_selection' => 'selected', + 'created_at' => '2025-06-15T08:30:00Z', + 'updated_at' => '2026-02-20T14:45:00Z', + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertSame(67890, $installation->id); + $this->assertSame('my-org', $installation->account->login); + $this->assertSame(999, $installation->account->id); + $this->assertSame('Organization', $installation->account->type); + $this->assertSame('selected', $installation->repositorySelection); + $this->assertSame('2025-06-15T08:30:00Z', $installation->createdAt); + $this->assertSame('2026-02-20T14:45:00Z', $installation->updatedAt); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'id' => 111, + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertSame(111, $installation->id); + $this->assertSame('', $installation->account->login); + $this->assertSame(0, $installation->account->id); + $this->assertSame('', $installation->repositorySelection); + $this->assertSame('', $installation->createdAt); + $this->assertSame('', $installation->updatedAt); + } + + public function testToStringReturnsStringOfId(): void + { + $account = new GithubUser('test', 1, '', '', ''); + $installation = new GithubInstallation( + id: 54321, + account: $account, + repositorySelection: 'all', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + ); + + $this->assertSame('54321', (string) $installation); + $this->assertSame('54321', $installation->__toString()); + } + + public function testAccountGithubUserParsing(): void + { + $data = json_decode(json_encode([ + 'id' => 123, + 'account' => [ + 'login' => 'bot-account', + 'id' => 777, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/777', + 'html_url' => 'https://github.com/bot-account', + 'type' => 'Bot', + ], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-02-01T00:00:00Z', + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertInstanceOf(GithubUser::class, $installation->account); + $this->assertSame('bot-account', $installation->account->login); + $this->assertSame(777, $installation->account->id); + $this->assertSame('Bot', $installation->account->type); + } +} diff --git a/test/unit/GithubLabelTest.php b/test/unit/GithubLabelTest.php index f990227..634d436 100644 --- a/test/unit/GithubLabelTest.php +++ b/test/unit/GithubLabelTest.php @@ -53,7 +53,7 @@ public function testFromApiResponseWithCompleteData(): void $data = (object) [ 'name' => 'documentation', 'color' => '0075ca', - 'description' => 'Improvements or additions to documentation' + 'description' => 'Improvements or additions to documentation', ]; $label = GithubLabel::fromApiResponse($data); @@ -67,7 +67,7 @@ public function testFromApiResponseWithoutDescription(): void { $data = (object) [ 'name' => 'wontfix', - 'color' => 'ffffff' + 'color' => 'ffffff', ]; $label = GithubLabel::fromApiResponse($data); diff --git a/test/unit/GithubRepositoryTest.php b/test/unit/GithubRepositoryTest.php index 027287d..385e129 100644 --- a/test/unit/GithubRepositoryTest.php +++ b/test/unit/GithubRepositoryTest.php @@ -70,7 +70,7 @@ public function testFromApiArrayParsesOwnerFromFullName(): void 'name' => 'components', 'full_name' => 'horde/components', 'description' => 'Component management tool', - 'clone_url' => 'https://github.com/horde/components.git' + 'clone_url' => 'https://github.com/horde/components.git', ]; $repo = GithubRepository::fromApiArray($data); diff --git a/test/unit/GithubReviewTest.php b/test/unit/GithubReviewTest.php index d3f48d9..3bbf4e6 100644 --- a/test/unit/GithubReviewTest.php +++ b/test/unit/GithubReviewTest.php @@ -75,13 +75,13 @@ public function testFromApiResponseWithCompleteData(): void 'id' => 333, 'avatar_url' => 'https://avatar.example.com/apireviewer.png', 'html_url' => 'https://github.com/apireviewer', - 'type' => 'User' + 'type' => 'User', ], 'body' => 'API review comment', 'state' => 'CHANGES_REQUESTED', 'html_url' => 'https://github.com/org/repo/pull/5#pullrequestreview-111222', 'submitted_at' => '2026-02-15T14:30:00Z', - 'commit_id' => 'def789ghi012' + 'commit_id' => 'def789ghi012', ]; $review = GithubReview::fromApiResponse($data); @@ -98,7 +98,7 @@ public function testFromApiResponseWithCompleteData(): void public function testFromApiResponseWithMinimalData(): void { $data = (object) [ - 'user' => (object) [] + 'user' => (object) [], ]; $review = GithubReview::fromApiResponse($data); diff --git a/test/unit/GithubStatusTest.php b/test/unit/GithubStatusTest.php index f0ecec2..ad9ec00 100644 --- a/test/unit/GithubStatusTest.php +++ b/test/unit/GithubStatusTest.php @@ -56,7 +56,7 @@ public function testCommitStatusFromApiResponse(): void 'description' => 'Running integration tests', 'target_url' => 'https://ci.example.com/build/789', 'created_at' => '2026-02-26T11:00:00Z', - 'updated_at' => '2026-02-26T11:01:00Z' + 'updated_at' => '2026-02-26T11:01:00Z', ]; $status = GithubCommitStatus::fromApiResponse($data); @@ -128,7 +128,7 @@ public function testCombinedStatusFromApiResponse(): void 'description' => 'Test 1 passed', 'target_url' => 'https://ci.example.com/1', 'created_at' => '2026-02-26T12:00:00Z', - 'updated_at' => '2026-02-26T12:01:00Z' + 'updated_at' => '2026-02-26T12:01:00Z', ], (object) [ 'state' => 'success', @@ -136,9 +136,9 @@ public function testCombinedStatusFromApiResponse(): void 'description' => 'Test 2 passed', 'target_url' => 'https://ci.example.com/2', 'created_at' => '2026-02-26T12:00:00Z', - 'updated_at' => '2026-02-26T12:02:00Z' - ] - ] + 'updated_at' => '2026-02-26T12:02:00Z', + ], + ], ]; $combined = GithubCombinedStatus::fromApiResponse($data); diff --git a/test/unit/GithubUserTest.php b/test/unit/GithubUserTest.php index cfaaef2..5872d3a 100644 --- a/test/unit/GithubUserTest.php +++ b/test/unit/GithubUserTest.php @@ -61,7 +61,7 @@ public function testFromApiResponseWithCompleteData(): void 'id' => 99999, 'avatar_url' => 'https://avatar.example.com/apiuser.png', 'html_url' => 'https://github.com/apiuser', - 'type' => 'Bot' + 'type' => 'Bot', ]; $user = GithubUser::fromApiResponse($data); @@ -90,7 +90,7 @@ public function testFromApiResponseWithPartialData(): void { $data = (object) [ 'login' => 'partial', - 'id' => 54321 + 'id' => 54321, ]; $user = GithubUser::fromApiResponse($data); diff --git a/test/unit/InstallationAccessTokenTest.php b/test/unit/InstallationAccessTokenTest.php index 54aedc1..8d31ada 100644 --- a/test/unit/InstallationAccessTokenTest.php +++ b/test/unit/InstallationAccessTokenTest.php @@ -1,138 +1,138 @@ - 'read', 'issues' => 'write'], - repositorySelection: 'selected' - ); - - $this->assertSame('ghs_test123', $token->token); - $this->assertSame('2026-03-02T12:00:00Z', $token->expiresAt); - $this->assertSame(['contents' => 'read', 'issues' => 'write'], $token->permissions); - $this->assertSame('selected', $token->repositorySelection); - } - - public function testFromApiResponseWithCompleteData(): void - { - $data = json_decode(json_encode([ - 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', - 'expires_at' => '2026-03-02T13:00:00Z', - 'permissions' => [ - 'contents' => 'write', - 'issues' => 'read', - 'metadata' => 'read' - ], - 'repository_selection' => 'all' - ])); - - $token = InstallationAccessToken::fromApiResponse($data); - - $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); - $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); - $this->assertSame([ - 'contents' => 'write', - 'issues' => 'read', - 'metadata' => 'read' - ], $token->permissions); - $this->assertSame('all', $token->repositorySelection); - } - - public function testFromApiResponseWithMinimalData(): void - { - $data = json_decode(json_encode([ - 'token' => 'ghs_minimal', - 'expires_at' => '2026-03-02T14:00:00Z' - ])); - - $token = InstallationAccessToken::fromApiResponse($data); - - $this->assertSame('ghs_minimal', $token->token); - $this->assertSame('2026-03-02T14:00:00Z', $token->expiresAt); - $this->assertSame([], $token->permissions); - $this->assertSame('', $token->repositorySelection); - } - - public function testPermissionsArrayHandling(): void - { - $data = json_decode(json_encode([ - 'token' => 'ghs_test', - 'expires_at' => '2026-03-02T15:00:00Z', - 'permissions' => [ - 'pull_requests' => 'write', - 'checks' => 'read' - ], - 'repository_selection' => 'selected' - ])); - - $token = InstallationAccessToken::fromApiResponse($data); - - $this->assertIsArray($token->permissions); - $this->assertCount(2, $token->permissions); - $this->assertArrayHasKey('pull_requests', $token->permissions); - $this->assertArrayHasKey('checks', $token->permissions); - } - - public function testRepositorySelectionValues(): void - { - // Test 'all' selection - $dataAll = json_decode(json_encode([ - 'token' => 'ghs_all', - 'expires_at' => '2026-03-02T16:00:00Z', - 'repository_selection' => 'all' - ])); - - $tokenAll = InstallationAccessToken::fromApiResponse($dataAll); - $this->assertSame('all', $tokenAll->repositorySelection); - - // Test 'selected' selection - $dataSelected = json_decode(json_encode([ - 'token' => 'ghs_selected', - 'expires_at' => '2026-03-02T17:00:00Z', - 'repository_selection' => 'selected' - ])); - - $tokenSelected = InstallationAccessToken::fromApiResponse($dataSelected); - $this->assertSame('selected', $tokenSelected->repositorySelection); - } - - public function testDoesNotImplementStringable(): void - { - $token = new InstallationAccessToken( - token: 'ghs_secret', - expiresAt: '2026-03-02T18:00:00Z', - permissions: [], - repositorySelection: 'all' - ); - - // Verify token doesn't implement Stringable (for security) - $reflection = new \ReflectionClass($token); - $interfaces = $reflection->getInterfaceNames(); - - $this->assertNotContains('Stringable', $interfaces); - } -} + 'read', 'issues' => 'write'], + repositorySelection: 'selected' + ); + + $this->assertSame('ghs_test123', $token->token); + $this->assertSame('2026-03-02T12:00:00Z', $token->expiresAt); + $this->assertSame(['contents' => 'read', 'issues' => 'write'], $token->permissions); + $this->assertSame('selected', $token->repositorySelection); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', + 'expires_at' => '2026-03-02T13:00:00Z', + 'permissions' => [ + 'contents' => 'write', + 'issues' => 'read', + 'metadata' => 'read', + ], + 'repository_selection' => 'all', + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); + $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); + $this->assertSame([ + 'contents' => 'write', + 'issues' => 'read', + 'metadata' => 'read', + ], $token->permissions); + $this->assertSame('all', $token->repositorySelection); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_minimal', + 'expires_at' => '2026-03-02T14:00:00Z', + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertSame('ghs_minimal', $token->token); + $this->assertSame('2026-03-02T14:00:00Z', $token->expiresAt); + $this->assertSame([], $token->permissions); + $this->assertSame('', $token->repositorySelection); + } + + public function testPermissionsArrayHandling(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_test', + 'expires_at' => '2026-03-02T15:00:00Z', + 'permissions' => [ + 'pull_requests' => 'write', + 'checks' => 'read', + ], + 'repository_selection' => 'selected', + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertIsArray($token->permissions); + $this->assertCount(2, $token->permissions); + $this->assertArrayHasKey('pull_requests', $token->permissions); + $this->assertArrayHasKey('checks', $token->permissions); + } + + public function testRepositorySelectionValues(): void + { + // Test 'all' selection + $dataAll = json_decode(json_encode([ + 'token' => 'ghs_all', + 'expires_at' => '2026-03-02T16:00:00Z', + 'repository_selection' => 'all', + ])); + + $tokenAll = InstallationAccessToken::fromApiResponse($dataAll); + $this->assertSame('all', $tokenAll->repositorySelection); + + // Test 'selected' selection + $dataSelected = json_decode(json_encode([ + 'token' => 'ghs_selected', + 'expires_at' => '2026-03-02T17:00:00Z', + 'repository_selection' => 'selected', + ])); + + $tokenSelected = InstallationAccessToken::fromApiResponse($dataSelected); + $this->assertSame('selected', $tokenSelected->repositorySelection); + } + + public function testDoesNotImplementStringable(): void + { + $token = new InstallationAccessToken( + token: 'ghs_secret', + expiresAt: '2026-03-02T18:00:00Z', + permissions: [], + repositorySelection: 'all' + ); + + // Verify token doesn't implement Stringable (for security) + $reflection = new \ReflectionClass($token); + $interfaces = $reflection->getInterfaceNames(); + + $this->assertNotContains('Stringable', $interfaces); + } +} diff --git a/test/unit/MergeTest.php b/test/unit/MergeTest.php index db68797..db43bc7 100644 --- a/test/unit/MergeTest.php +++ b/test/unit/MergeTest.php @@ -62,7 +62,7 @@ public function testMergePullRequestParamsToArrayWithAllFields(): void 'commit_title' => 'Test title', 'commit_message' => 'Test message', 'merge_method' => 'squash', - 'sha' => 'abc123' + 'sha' => 'abc123', ], $array); } @@ -133,7 +133,7 @@ public function testMergeResultFromApiResponseWhenMerged(): void $data = (object) [ 'sha' => 'ghi789jkl012', 'merged' => true, - 'message' => 'Pull Request successfully merged' + 'message' => 'Pull Request successfully merged', ]; $result = MergeResult::fromApiResponse($data); @@ -148,7 +148,7 @@ public function testMergeResultFromApiResponseWhenNotMerged(): void $data = (object) [ 'sha' => '', 'merged' => false, - 'message' => 'Merge conflict' + 'message' => 'Merge conflict', ]; $result = MergeResult::fromApiResponse($data);