diff --git a/.horde.yml b/.horde.yml index 03b65f7..a9eb566 100644 --- a/.horde.yml +++ b/.horde.yml @@ -26,6 +26,9 @@ dependencies: php: ^8.3 composer: horde/http: '*' + psr/http-client: ^1.0 + psr/http-factory: ^1.0 + psr/http-message: ^2.0 dev: composer: phpunit/phpunit: ^12 diff --git a/composer.json b/composer.json index 1b438af..a3d55df 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,10 @@ "repositories": [], "require": { "php": "^8.3", - "horde/http": "* || dev-FRAMEWORK_6_0" + "horde/http": "* || dev-FRAMEWORK_6_0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0" }, "require-dev": { "phpunit/phpunit": "^12", @@ -44,5 +47,6 @@ "branch-alias": { "dev-FRAMEWORK_6_0": "0.x-dev" } - } -} \ No newline at end of file + }, + "minimum-stability": "dev" +} diff --git a/src/CreateReleaseParams.php b/src/CreateReleaseParams.php new file mode 100644 index 0000000..47b4e58 --- /dev/null +++ b/src/CreateReleaseParams.php @@ -0,0 +1,65 @@ + + */ + public function toArray(): array + { + $data = [ + 'tag_name' => $this->tagName, + 'draft' => $this->draft, + 'prerelease' => $this->prerelease, + ]; + + if ($this->name !== '') { + $data['name'] = $this->name; + } + + if ($this->body !== '') { + $data['body'] = $this->body; + } + + if ($this->targetCommitish !== '') { + $data['target_commitish'] = $this->targetCommitish; + } + + return $data; + } +} diff --git a/src/CreateReleaseRequestFactory.php b/src/CreateReleaseRequestFactory.php new file mode 100644 index 0000000..445f9be --- /dev/null +++ b/src/CreateReleaseRequestFactory.php @@ -0,0 +1,57 @@ +repo->owner, + $this->repo->name, + ); + + $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', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/GetReleaseByTagRequestFactory.php b/src/GetReleaseByTagRequestFactory.php new file mode 100644 index 0000000..507033c --- /dev/null +++ b/src/GetReleaseByTagRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + rawurlencode($this->tag), + ); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index c3c36b5..35d9411 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -653,6 +653,136 @@ public function createPullRequest(GithubRepository $repo, CreatePullRequestParam } } + /** + * Create a new release for a tag + * + * @param GithubRepository $repo The repository + * @param CreateReleaseParams $params The release parameters + * @return GithubRelease The created release + * @throws Exception + */ + public function createRelease(GithubRepository $repo, CreateReleaseParams $params): GithubRelease + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createRelease. Please provide it in the constructor.'); + } + + $requestFactory = new CreateReleaseRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + return GithubRelease::fromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Get a release by tag name + * + * @param GithubRepository $repo The repository + * @param string $tag The tag name + * @return GithubRelease The release + * @throws Exception + */ + public function getReleaseByTag(GithubRepository $repo, string $tag): GithubRelease + { + $requestFactory = new GetReleaseByTagRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $tag + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubRelease::fromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Update an existing release + * + * @param GithubRepository $repo The repository + * @param int $releaseId The release ID + * @param UpdateReleaseParams $params The update parameters + * @return GithubRelease The updated release + * @throws Exception + */ + public function updateRelease(GithubRepository $repo, int $releaseId, UpdateReleaseParams $params): GithubRelease + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateRelease. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateReleaseRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $releaseId, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubRelease::fromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Upload an asset to a release + * + * @param string $uploadUrl The upload URL from the release object + * @param string $filename The filename for the asset + * @param string $fileContent The file content + * @param string $contentType The MIME type (default: application/octet-stream) + * @return GithubReleaseAsset The uploaded asset + * @throws Exception + */ + public function uploadReleaseAsset(string $uploadUrl, string $filename, string $fileContent, string $contentType = 'application/octet-stream'): GithubReleaseAsset + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for uploadReleaseAsset. Please provide it in the constructor.'); + } + + $stream = $this->streamFactory->createStream($fileContent); + + $requestFactory = new UploadReleaseAssetRequestFactory( + $this->requestFactory, + $this->config, + $uploadUrl, + $filename, + $stream, + $contentType + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + return GithubReleaseAsset::fromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + /** * @param array $repos * @param string $json diff --git a/src/GithubRelease.php b/src/GithubRelease.php new file mode 100644 index 0000000..ab242e1 --- /dev/null +++ b/src/GithubRelease.php @@ -0,0 +1,89 @@ + $assets Release assets + */ + public function __construct( + public readonly int $id, + public readonly string $tagName, + public readonly string $name, + public readonly string $body, + public readonly bool $draft, + public readonly bool $prerelease, + public readonly string $createdAt, + public readonly string $publishedAt, + public readonly string $htmlUrl, + public readonly string $uploadUrl, + public readonly GithubUser $author, + public readonly array $assets, + ) {} + + public function __toString(): string + { + return $this->htmlUrl; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $author = isset($data->author) ? GithubUser::fromApiResponse($data->author) : new GithubUser('', 0, '', '', ''); + + $assets = []; + if (isset($data->assets) && is_array($data->assets)) { + foreach ($data->assets as $assetData) { + $assets[] = GithubReleaseAsset::fromApiResponse($assetData); + } + } + + return new self( + id: $data->id ?? 0, + tagName: $data->tag_name ?? '', + name: $data->name ?? '', + body: $data->body ?? '', + draft: $data->draft ?? false, + prerelease: $data->prerelease ?? false, + createdAt: $data->created_at ?? '', + publishedAt: $data->published_at ?? '', + htmlUrl: $data->html_url ?? '', + uploadUrl: $data->upload_url ?? '', + author: $author, + assets: $assets, + ); + } +} diff --git a/src/GithubReleaseAsset.php b/src/GithubReleaseAsset.php new file mode 100644 index 0000000..c069968 --- /dev/null +++ b/src/GithubReleaseAsset.php @@ -0,0 +1,62 @@ +browserDownloadUrl; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + id: $data->id ?? 0, + name: $data->name ?? '', + label: $data->label ?? '', + contentType: $data->content_type ?? '', + size: $data->size ?? 0, + downloadCount: $data->download_count ?? 0, + state: $data->state ?? '', + browserDownloadUrl: $data->browser_download_url ?? '', + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '', + ); + } +} diff --git a/src/UpdateReleaseParams.php b/src/UpdateReleaseParams.php new file mode 100644 index 0000000..b088625 --- /dev/null +++ b/src/UpdateReleaseParams.php @@ -0,0 +1,74 @@ + + */ + public function toArray(): array + { + $data = []; + + if ($this->tagName !== null) { + $data['tag_name'] = $this->tagName; + } + + if ($this->name !== null) { + $data['name'] = $this->name; + } + + if ($this->body !== null) { + $data['body'] = $this->body; + } + + if ($this->draft !== null) { + $data['draft'] = $this->draft; + } + + if ($this->prerelease !== null) { + $data['prerelease'] = $this->prerelease; + } + + return $data; + } + + /** + * Check if any field is set + * + * @return bool + */ + public function isEmpty(): bool + { + return $this->tagName === null + && $this->name === null + && $this->body === null + && $this->draft === null + && $this->prerelease === null; + } +} diff --git a/src/UpdateReleaseRequestFactory.php b/src/UpdateReleaseRequestFactory.php new file mode 100644 index 0000000..e184006 --- /dev/null +++ b/src/UpdateReleaseRequestFactory.php @@ -0,0 +1,59 @@ +repo->owner, + $this->repo->name, + $this->releaseId, + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PATCH', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/UploadReleaseAssetRequestFactory.php b/src/UploadReleaseAssetRequestFactory.php new file mode 100644 index 0000000..be1289b --- /dev/null +++ b/src/UploadReleaseAssetRequestFactory.php @@ -0,0 +1,56 @@ +uploadUrl); + + // Add filename as query parameter + $url .= '?name=' . rawurlencode($this->filename); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', $this->contentType); + $request = $request->withBody($this->content); + + return $request; + } +} diff --git a/test/unit/CreateReleaseParamsTest.php b/test/unit/CreateReleaseParamsTest.php new file mode 100644 index 0000000..ae7ea05 --- /dev/null +++ b/test/unit/CreateReleaseParamsTest.php @@ -0,0 +1,214 @@ +assertSame('v1.0.0', $params->tagName); + $this->assertSame('', $params->name); + $this->assertSame('', $params->body); + $this->assertSame('', $params->targetCommitish); + $this->assertFalse($params->draft); + $this->assertFalse($params->prerelease); + } + + public function testFullConstruction(): void + { + $params = new CreateReleaseParams( + tagName: 'v2.0.0', + name: 'Release 2.0.0', + body: 'Major release with breaking changes', + targetCommitish: 'main', + draft: true, + prerelease: true, + ); + + $this->assertSame('v2.0.0', $params->tagName); + $this->assertSame('Release 2.0.0', $params->name); + $this->assertSame('Major release with breaking changes', $params->body); + $this->assertSame('main', $params->targetCommitish); + $this->assertTrue($params->draft); + $this->assertTrue($params->prerelease); + } + + public function testToArrayMinimal(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0', + ); + + $array = $params->toArray(); + + $this->assertSame([ + 'tag_name' => 'v1.0.0', + 'draft' => false, + 'prerelease' => false, + ], $array); + } + + public function testToArrayFull(): void + { + $params = new CreateReleaseParams( + tagName: 'v2.0.0', + name: 'Release 2.0.0', + body: 'Major release', + targetCommitish: 'develop', + draft: true, + prerelease: false, + ); + + $array = $params->toArray(); + + // Order matches toArray() implementation + $this->assertArrayHasKey('tag_name', $array); + $this->assertArrayHasKey('draft', $array); + $this->assertArrayHasKey('prerelease', $array); + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('body', $array); + $this->assertArrayHasKey('target_commitish', $array); + + $this->assertSame('v2.0.0', $array['tag_name']); + $this->assertSame('Release 2.0.0', $array['name']); + $this->assertSame('Major release', $array['body']); + $this->assertSame('develop', $array['target_commitish']); + $this->assertTrue($array['draft']); + $this->assertFalse($array['prerelease']); + } + + public function testToArrayWithEmptyStrings(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0', + name: '', + body: '', + ); + + $array = $params->toArray(); + + // Empty strings should not be included + $this->assertSame([ + 'tag_name' => 'v1.0.0', + 'draft' => false, + 'prerelease' => false, + ], $array); + } + + public function testToArrayWithOnlyName(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0', + name: 'Version 1.0.0', + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('tag_name', $array); + $this->assertArrayHasKey('draft', $array); + $this->assertArrayHasKey('prerelease', $array); + $this->assertArrayHasKey('name', $array); + $this->assertSame('v1.0.0', $array['tag_name']); + $this->assertSame('Version 1.0.0', $array['name']); + $this->assertFalse($array['draft']); + $this->assertFalse($array['prerelease']); + } + + public function testToArrayWithOnlyBody(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0', + body: 'Release notes', + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('tag_name', $array); + $this->assertArrayHasKey('draft', $array); + $this->assertArrayHasKey('prerelease', $array); + $this->assertArrayHasKey('body', $array); + $this->assertSame('v1.0.0', $array['tag_name']); + $this->assertSame('Release notes', $array['body']); + $this->assertFalse($array['draft']); + $this->assertFalse($array['prerelease']); + } + + public function testToArrayWithOnlyTargetCommitish(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0', + targetCommitish: 'abc123def', + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('tag_name', $array); + $this->assertArrayHasKey('draft', $array); + $this->assertArrayHasKey('prerelease', $array); + $this->assertArrayHasKey('target_commitish', $array); + $this->assertSame('v1.0.0', $array['tag_name']); + $this->assertSame('abc123def', $array['target_commitish']); + $this->assertFalse($array['draft']); + $this->assertFalse($array['prerelease']); + } + + public function testDraftRelease(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0-rc1', + draft: true, + ); + + $array = $params->toArray(); + + $this->assertTrue($array['draft']); + $this->assertFalse($array['prerelease']); + } + + public function testPrereleaseRelease(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0-beta.1', + prerelease: true, + ); + + $array = $params->toArray(); + + $this->assertFalse($array['draft']); + $this->assertTrue($array['prerelease']); + } + + public function testDraftPrerelease(): void + { + $params = new CreateReleaseParams( + tagName: 'v1.0.0-alpha.1', + draft: true, + prerelease: true, + ); + + $array = $params->toArray(); + + $this->assertTrue($array['draft']); + $this->assertTrue($array['prerelease']); + } +} diff --git a/test/unit/GithubApiClientReleaseTest.php b/test/unit/GithubApiClientReleaseTest.php new file mode 100644 index 0000000..147dc33 --- /dev/null +++ b/test/unit/GithubApiClientReleaseTest.php @@ -0,0 +1,457 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBody = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $responseBodyContent = json_encode([ + 'id' => 123, + 'tag_name' => 'v1.0.0', + 'name' => 'Version 1.0.0', + 'body' => 'Release notes', + 'draft' => false, + 'prerelease' => false, + 'created_at' => '2026-01-01T10:00:00Z', + 'published_at' => '2026-01-01T11:00:00Z', + 'html_url' => 'https://github.com/owner/repo/releases/tag/v1.0.0', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}', + 'author' => [ + 'login' => 'octocat', + 'id' => 1, + 'avatar_url' => 'https://github.com/images/error/octocat_happy.gif', + 'html_url' => 'https://github.com/octocat', + ], + 'assets' => [], + ]); + + $responseBody->method('__toString')->willReturn($responseBodyContent); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($responseBody); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateReleaseParams(tagName: 'v1.0.0', name: 'Version 1.0.0'); + + $release = $client->createRelease($repo, $params); + + $this->assertInstanceOf(GithubRelease::class, $release); + $this->assertSame(123, $release->id); + $this->assertSame('v1.0.0', $release->tagName); + $this->assertSame('Version 1.0.0', $release->name); + } + + public function testCreateReleaseThrowsExceptionWithoutStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateReleaseParams(tagName: 'v1.0.0'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createRelease'); + + $client->createRelease($repo, $params); + } + + public function testCreateReleaseThrowsOn422(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $response->method('getStatusCode')->willReturn(422); + $response->method('getReasonPhrase')->willReturn('Unprocessable Entity'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new CreateReleaseParams(tagName: 'v1.0.0'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('422 Unprocessable Entity'); + + $client->createRelease($repo, $params); + } + + public function testGetReleaseByTagSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBodyContent = json_encode([ + 'id' => 456, + 'tag_name' => 'v2.0.0', + 'name' => 'Version 2.0.0', + 'body' => 'Major release', + 'draft' => false, + 'prerelease' => false, + 'created_at' => '2026-02-01T12:00:00Z', + 'published_at' => '2026-02-01T13:00:00Z', + 'html_url' => 'https://github.com/owner/repo/releases/tag/v2.0.0', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/456/assets{?name,label}', + 'author' => [ + 'login' => 'developer', + 'id' => 99, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/99', + 'html_url' => 'https://github.com/developer', + ], + 'assets' => [ + [ + 'id' => 789, + 'name' => 'release.tar.gz', + 'label' => 'Source code', + 'content_type' => 'application/gzip', + 'size' => 2048, + 'download_count' => 100, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v2.0.0/release.tar.gz', + 'created_at' => '2026-02-01T12:30:00Z', + 'updated_at' => '2026-02-01T12:30:00Z', + ], + ], + ]); + + $response->method('getStatusCode')->willReturn(200); + $responseBodyStream->method('__toString')->willReturn($responseBodyContent); + $response->method('getBody')->willReturn($responseBodyStream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $release = $client->getReleaseByTag($repo, 'v2.0.0'); + + $this->assertInstanceOf(GithubRelease::class, $release); + $this->assertSame(456, $release->id); + $this->assertSame('v2.0.0', $release->tagName); + $this->assertCount(1, $release->assets); + } + + public function testGetReleaseByTagThrowsOn404(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->getReleaseByTag($repo, 'nonexistent-tag'); + } + + public function testUpdateReleaseSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $responseBodyContent = json_encode([ + 'id' => 123, + 'tag_name' => 'v1.0.1', + 'name' => 'Version 1.0.1', + 'body' => 'Updated release notes', + 'draft' => false, + 'prerelease' => false, + 'created_at' => '2026-01-01T10:00:00Z', + 'published_at' => '2026-01-01T12:00:00Z', + 'html_url' => 'https://github.com/owner/repo/releases/tag/v1.0.1', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}', + 'author' => [ + 'login' => 'octocat', + 'id' => 1, + 'avatar_url' => 'https://github.com/images/error/octocat_happy.gif', + 'html_url' => 'https://github.com/octocat', + ], + 'assets' => [], + ]); + + $response->method('getStatusCode')->willReturn(200); + $responseBodyStream->method('__toString')->willReturn($responseBodyContent); + $response->method('getBody')->willReturn($responseBodyStream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new UpdateReleaseParams(body: 'Updated release notes'); + + $release = $client->updateRelease($repo, 123, $params); + + $this->assertInstanceOf(GithubRelease::class, $release); + $this->assertSame(123, $release->id); + $this->assertSame('Updated release notes', $release->body); + } + + public function testUpdateReleaseThrowsExceptionWithoutStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new UpdateReleaseParams(draft: false); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for updateRelease'); + + $client->updateRelease($repo, 123, $params); + } + + public function testUpdateReleaseThrowsOn404(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('owner/repo'); + $params = new UpdateReleaseParams(name: 'Updated Name'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->updateRelease($repo, 99999, $params); + } + + public function testUploadReleaseAssetSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $responseBodyContent = json_encode([ + 'id' => 999, + 'name' => 'release.zip', + 'label' => 'Release archive', + 'content_type' => 'application/zip', + 'size' => 4096, + 'download_count' => 0, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v1.0.0/release.zip', + 'created_at' => '2026-01-01T15:00:00Z', + 'updated_at' => '2026-01-01T15:00:00Z', + ]); + + $response->method('getStatusCode')->willReturn(201); + $responseBodyStream->method('__toString')->willReturn($responseBodyContent); + $response->method('getBody')->willReturn($responseBodyStream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $uploadUrl = 'https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}'; + + $asset = $client->uploadReleaseAsset($uploadUrl, 'release.zip', 'fake-zip-content', 'application/zip'); + + $this->assertInstanceOf(GithubReleaseAsset::class, $asset); + $this->assertSame(999, $asset->id); + $this->assertSame('release.zip', $asset->name); + $this->assertSame('application/zip', $asset->contentType); + } + + public function testUploadReleaseAssetThrowsExceptionWithoutStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $uploadUrl = 'https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('StreamFactory is required for uploadReleaseAsset'); + + $client->uploadReleaseAsset($uploadUrl, 'file.txt', 'content'); + } + + public function testUploadReleaseAssetThrowsOn422(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $response->method('getStatusCode')->willReturn(422); + $response->method('getReasonPhrase')->willReturn('Unprocessable Entity'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $uploadUrl = 'https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('422 Unprocessable Entity'); + + $client->uploadReleaseAsset($uploadUrl, 'duplicate.zip', 'content'); + } + + public function testUploadReleaseAssetWithDefaultContentType(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + $responseBodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $responseBodyContent = json_encode([ + 'id' => 888, + 'name' => 'binary.bin', + 'label' => '', + 'content_type' => 'application/octet-stream', + 'size' => 1024, + 'download_count' => 0, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v1.0.0/binary.bin', + 'created_at' => '2026-01-01T16:00:00Z', + 'updated_at' => '2026-01-01T16:00:00Z', + ]); + + $response->method('getStatusCode')->willReturn(201); + $responseBodyStream->method('__toString')->willReturn($responseBodyContent); + $response->method('getBody')->willReturn($responseBodyStream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $uploadUrl = 'https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}'; + + // Test without specifying contentType (should default to application/octet-stream) + $asset = $client->uploadReleaseAsset($uploadUrl, 'binary.bin', 'binary-content'); + + $this->assertInstanceOf(GithubReleaseAsset::class, $asset); + $this->assertSame('application/octet-stream', $asset->contentType); + } +} diff --git a/test/unit/GithubReleaseAssetTest.php b/test/unit/GithubReleaseAssetTest.php new file mode 100644 index 0000000..3cef412 --- /dev/null +++ b/test/unit/GithubReleaseAssetTest.php @@ -0,0 +1,234 @@ +assertSame(123, $asset->id); + $this->assertSame('example.zip', $asset->name); + $this->assertSame('Example archive', $asset->label); + $this->assertSame('application/zip', $asset->contentType); + $this->assertSame(2048, $asset->size); + $this->assertSame(42, $asset->downloadCount); + $this->assertSame('uploaded', $asset->state); + $this->assertSame('https://github.com/releases/download/v1.0.0/example.zip', $asset->browserDownloadUrl); + $this->assertSame('2026-01-01T12:00:00Z', $asset->createdAt); + $this->assertSame('2026-01-01T12:30:00Z', $asset->updatedAt); + } + + public function testFromApiResponse(): void + { + $data = json_decode(json_encode([ + 'id' => 456, + 'name' => 'release.tar.gz', + 'label' => 'Source code', + 'content_type' => 'application/gzip', + 'size' => 4096, + 'download_count' => 150, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v2.0.0/release.tar.gz', + 'created_at' => '2026-02-01T10:00:00Z', + 'updated_at' => '2026-02-01T10:05:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame(456, $asset->id); + $this->assertSame('release.tar.gz', $asset->name); + $this->assertSame('Source code', $asset->label); + $this->assertSame('application/gzip', $asset->contentType); + $this->assertSame(4096, $asset->size); + $this->assertSame(150, $asset->downloadCount); + $this->assertSame('uploaded', $asset->state); + $this->assertSame('https://github.com/releases/download/v2.0.0/release.tar.gz', $asset->browserDownloadUrl); + $this->assertSame('2026-02-01T10:00:00Z', $asset->createdAt); + $this->assertSame('2026-02-01T10:05:00Z', $asset->updatedAt); + } + + public function testFromApiResponseWithEmptyLabel(): void + { + $data = json_decode(json_encode([ + 'id' => 789, + 'name' => 'binary.exe', + 'label' => '', + 'content_type' => 'application/octet-stream', + 'size' => 8192, + 'download_count' => 0, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v1.0.0/binary.exe', + 'created_at' => '2026-01-15T14:00:00Z', + 'updated_at' => '2026-01-15T14:00:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame('', $asset->label); + } + + public function testFromApiResponseZeroDownloads(): void + { + $data = json_decode(json_encode([ + 'id' => 111, + 'name' => 'new-asset.dmg', + 'label' => 'macOS installer', + 'content_type' => 'application/x-apple-diskimage', + 'size' => 16384, + 'download_count' => 0, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v3.0.0/new-asset.dmg', + 'created_at' => '2026-03-01T08:00:00Z', + 'updated_at' => '2026-03-01T08:00:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame(0, $asset->downloadCount); + } + + public function testFromApiResponseLargeFile(): void + { + $data = json_decode(json_encode([ + 'id' => 222, + 'name' => 'large-file.iso', + 'label' => 'Installation media', + 'content_type' => 'application/x-iso9660-image', + 'size' => 4294967296, // 4GB + 'download_count' => 1000, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v5.0.0/large-file.iso', + 'created_at' => '2026-02-15T00:00:00Z', + 'updated_at' => '2026-02-15T01:00:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame(4294967296, $asset->size); + } + + public function testFromApiResponseDifferentContentTypes(): void + { + $testCases = [ + 'application/zip', + 'application/gzip', + 'application/x-tar', + 'application/octet-stream', + 'application/x-apple-diskimage', + 'application/vnd.debian.binary-package', + 'application/x-rpm', + ]; + + foreach ($testCases as $index => $contentType) { + $data = json_decode(json_encode([ + 'id' => $index + 1, + 'name' => "file-{$index}.bin", + 'label' => "File {$index}", + 'content_type' => $contentType, + 'size' => 1024, + 'download_count' => 10, + 'state' => 'uploaded', + 'browser_download_url' => "https://github.com/releases/download/v1.0.0/file-{$index}.bin", + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame($contentType, $asset->contentType); + } + } + + public function testFromApiResponseDifferentStates(): void + { + $states = ['uploaded', 'open', 'starter']; + + foreach ($states as $index => $state) { + $data = json_decode(json_encode([ + 'id' => $index + 100, + 'name' => "asset-{$state}.bin", + 'label' => "Asset in {$state} state", + 'content_type' => 'application/octet-stream', + 'size' => 512, + 'download_count' => 5, + 'state' => $state, + 'browser_download_url' => "https://github.com/releases/download/v1.0.0/asset-{$state}.bin", + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame($state, $asset->state); + } + } + + public function testFromApiResponseHighDownloadCount(): void + { + $data = json_decode(json_encode([ + 'id' => 999, + 'name' => 'popular-asset.zip', + 'label' => 'Very popular', + 'content_type' => 'application/zip', + 'size' => 1024, + 'download_count' => 1000000, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v1.0.0/popular-asset.zip', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame(1000000, $asset->downloadCount); + } + + public function testFromApiResponseWithSpecialCharactersInName(): void + { + $data = json_decode(json_encode([ + 'id' => 333, + 'name' => 'my-app-v1.0.0-alpha.1+build.123.tar.gz', + 'label' => 'Special version naming', + 'content_type' => 'application/gzip', + 'size' => 2048, + 'download_count' => 25, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v1.0.0-alpha.1/my-app-v1.0.0-alpha.1+build.123.tar.gz', + 'created_at' => '2026-02-10T12:00:00Z', + 'updated_at' => '2026-02-10T12:00:00Z', + ])); + + $asset = GithubReleaseAsset::fromApiResponse($data); + + $this->assertSame('my-app-v1.0.0-alpha.1+build.123.tar.gz', $asset->name); + } +} diff --git a/test/unit/GithubReleaseTest.php b/test/unit/GithubReleaseTest.php new file mode 100644 index 0000000..44b4eab --- /dev/null +++ b/test/unit/GithubReleaseTest.php @@ -0,0 +1,284 @@ +assertSame(123, $release->id); + $this->assertSame('v1.0.0', $release->tagName); + $this->assertSame('Version 1.0.0', $release->name); + $this->assertSame('Release notes', $release->body); + $this->assertFalse($release->draft); + $this->assertFalse($release->prerelease); + $this->assertSame('2026-01-01T10:00:00Z', $release->createdAt); + $this->assertSame('2026-01-01T11:00:00Z', $release->publishedAt); + $this->assertSame('https://github.com/owner/repo/releases/tag/v1.0.0', $release->htmlUrl); + $this->assertSame('https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}', $release->uploadUrl); + $this->assertSame($author, $release->author); + $this->assertCount(1, $release->assets); + $this->assertSame($asset, $release->assets[0]); + } + + public function testFromApiResponseComplete(): void + { + $data = json_decode(json_encode([ + 'id' => 456, + 'tag_name' => 'v2.0.0', + 'name' => 'Version 2.0.0', + 'body' => 'Major release', + 'draft' => true, + 'prerelease' => false, + 'created_at' => '2026-02-01T12:00:00Z', + 'published_at' => '2026-02-01T13:00:00Z', + 'html_url' => 'https://github.com/owner/repo/releases/tag/v2.0.0', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/456/assets{?name,label}', + 'author' => [ + 'login' => 'developer', + 'id' => 99, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/99', + 'html_url' => 'https://github.com/developer', + ], + 'assets' => [ + [ + 'id' => 789, + 'name' => 'release.tar.gz', + 'label' => 'Source code', + 'content_type' => 'application/gzip', + 'size' => 2048, + 'download_count' => 100, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v2.0.0/release.tar.gz', + 'created_at' => '2026-02-01T12:30:00Z', + 'updated_at' => '2026-02-01T12:30:00Z', + ], + ], + ])); + + $release = GithubRelease::fromApiResponse($data); + + $this->assertSame(456, $release->id); + $this->assertSame('v2.0.0', $release->tagName); + $this->assertSame('Version 2.0.0', $release->name); + $this->assertSame('Major release', $release->body); + $this->assertTrue($release->draft); + $this->assertFalse($release->prerelease); + $this->assertSame('2026-02-01T12:00:00Z', $release->createdAt); + $this->assertSame('2026-02-01T13:00:00Z', $release->publishedAt); + $this->assertSame('https://github.com/owner/repo/releases/tag/v2.0.0', $release->htmlUrl); + $this->assertSame('https://uploads.github.com/repos/owner/repo/releases/456/assets{?name,label}', $release->uploadUrl); + $this->assertSame('developer', $release->author->login); + $this->assertCount(1, $release->assets); + $this->assertSame('release.tar.gz', $release->assets[0]->name); + } + + public function testFromApiResponseMinimal(): void + { + $data = json_decode(json_encode([ + 'id' => 1, + 'tag_name' => 'v0.1.0', + 'name' => '', + 'body' => '', + 'draft' => false, + 'prerelease' => true, + 'created_at' => '2026-01-15T08:00:00Z', + 'published_at' => null, + 'html_url' => 'https://github.com/owner/repo/releases/tag/v0.1.0', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/1/assets{?name,label}', + 'author' => [ + 'login' => 'bot', + 'id' => 1, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/1', + 'html_url' => 'https://github.com/bot', + ], + 'assets' => [], + ])); + + $release = GithubRelease::fromApiResponse($data); + + $this->assertSame(1, $release->id); + $this->assertSame('v0.1.0', $release->tagName); + $this->assertSame('', $release->name); + $this->assertSame('', $release->body); + $this->assertFalse($release->draft); + $this->assertTrue($release->prerelease); + $this->assertSame('2026-01-15T08:00:00Z', $release->createdAt); + $this->assertSame('', $release->publishedAt); + $this->assertCount(0, $release->assets); + } + + public function testFromApiResponseWithMultipleAssets(): void + { + $data = json_decode(json_encode([ + 'id' => 999, + 'tag_name' => 'v3.0.0', + 'name' => 'Version 3.0.0', + 'body' => 'Release with multiple assets', + 'draft' => false, + 'prerelease' => false, + 'created_at' => '2026-03-01T00:00:00Z', + 'published_at' => '2026-03-01T01:00:00Z', + 'html_url' => 'https://github.com/owner/repo/releases/tag/v3.0.0', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/999/assets{?name,label}', + 'author' => [ + 'login' => 'maintainer', + 'id' => 123, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/123', + 'html_url' => 'https://github.com/maintainer', + ], + 'assets' => [ + [ + 'id' => 1001, + 'name' => 'app.exe', + 'label' => 'Windows executable', + 'content_type' => 'application/octet-stream', + 'size' => 5120, + 'download_count' => 250, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v3.0.0/app.exe', + 'created_at' => '2026-03-01T00:30:00Z', + 'updated_at' => '2026-03-01T00:30:00Z', + ], + [ + 'id' => 1002, + 'name' => 'app.dmg', + 'label' => 'macOS disk image', + 'content_type' => 'application/x-apple-diskimage', + 'size' => 10240, + 'download_count' => 180, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v3.0.0/app.dmg', + 'created_at' => '2026-03-01T00:35:00Z', + 'updated_at' => '2026-03-01T00:35:00Z', + ], + [ + 'id' => 1003, + 'name' => 'app.tar.gz', + 'label' => 'Linux binary', + 'content_type' => 'application/gzip', + 'size' => 3072, + 'download_count' => 320, + 'state' => 'uploaded', + 'browser_download_url' => 'https://github.com/releases/download/v3.0.0/app.tar.gz', + 'created_at' => '2026-03-01T00:40:00Z', + 'updated_at' => '2026-03-01T00:40:00Z', + ], + ], + ])); + + $release = GithubRelease::fromApiResponse($data); + + $this->assertCount(3, $release->assets); + $this->assertSame('app.exe', $release->assets[0]->name); + $this->assertSame('app.dmg', $release->assets[1]->name); + $this->assertSame('app.tar.gz', $release->assets[2]->name); + } + + public function testFromApiResponseDraftRelease(): void + { + $data = json_decode(json_encode([ + 'id' => 111, + 'tag_name' => 'v1.0.0-rc1', + 'name' => 'Release Candidate 1', + 'body' => 'Testing before final release', + 'draft' => true, + 'prerelease' => true, + 'created_at' => '2026-02-20T10:00:00Z', + 'published_at' => null, + 'html_url' => 'https://github.com/owner/repo/releases/tag/v1.0.0-rc1', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/111/assets{?name,label}', + 'author' => [ + 'login' => 'tester', + 'id' => 50, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/50', + 'html_url' => 'https://github.com/tester', + ], + 'assets' => [], + ])); + + $release = GithubRelease::fromApiResponse($data); + + $this->assertTrue($release->draft); + $this->assertTrue($release->prerelease); + $this->assertSame('', $release->publishedAt); + } + + public function testFromApiResponseWithNullPublishedAt(): void + { + $data = json_decode(json_encode([ + 'id' => 222, + 'tag_name' => 'v1.5.0', + 'name' => 'Unpublished', + 'body' => 'Not yet published', + 'draft' => true, + 'prerelease' => false, + 'created_at' => '2026-02-25T15:00:00Z', + 'published_at' => null, + 'html_url' => 'https://github.com/owner/repo/releases/tag/v1.5.0', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/222/assets{?name,label}', + 'author' => [ + 'login' => 'author', + 'id' => 10, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/10', + 'html_url' => 'https://github.com/author', + ], + 'assets' => [], + ])); + + $release = GithubRelease::fromApiResponse($data); + + $this->assertSame('', $release->publishedAt); + } +} diff --git a/test/unit/UpdateReleaseParamsTest.php b/test/unit/UpdateReleaseParamsTest.php new file mode 100644 index 0000000..282da98 --- /dev/null +++ b/test/unit/UpdateReleaseParamsTest.php @@ -0,0 +1,218 @@ +assertNull($params->tagName); + $this->assertNull($params->name); + $this->assertNull($params->body); + $this->assertNull($params->targetCommitish); + $this->assertNull($params->draft); + $this->assertNull($params->prerelease); + } + + public function testFullConstruction(): void + { + $params = new UpdateReleaseParams( + tagName: 'v2.1.0', + name: 'Updated Release', + body: 'Updated release notes', + draft: false, + prerelease: true, + ); + + $this->assertSame('v2.1.0', $params->tagName); + $this->assertSame('Updated Release', $params->name); + $this->assertSame('Updated release notes', $params->body); + $this->assertFalse($params->draft); + $this->assertTrue($params->prerelease); + } + + public function testIsEmptyWithNoParameters(): void + { + $params = new UpdateReleaseParams(); + + $this->assertTrue($params->isEmpty()); + } + + public function testIsEmptyWithTagName(): void + { + $params = new UpdateReleaseParams(tagName: 'v1.0.0'); + + $this->assertFalse($params->isEmpty()); + } + + public function testIsEmptyWithName(): void + { + $params = new UpdateReleaseParams(name: 'Release Name'); + + $this->assertFalse($params->isEmpty()); + } + + public function testIsEmptyWithBody(): void + { + $params = new UpdateReleaseParams(body: 'Release body'); + + $this->assertFalse($params->isEmpty()); + } + + public function testIsEmptyWithDraft(): void + { + $params = new UpdateReleaseParams(draft: true); + + $this->assertFalse($params->isEmpty()); + } + + public function testIsEmptyWithPrerelease(): void + { + $params = new UpdateReleaseParams(prerelease: false); + + $this->assertFalse($params->isEmpty()); + } + + public function testToArrayEmpty(): void + { + $params = new UpdateReleaseParams(); + + $array = $params->toArray(); + + $this->assertSame([], $array); + } + + public function testToArrayWithTagName(): void + { + $params = new UpdateReleaseParams(tagName: 'v2.0.0'); + + $array = $params->toArray(); + + $this->assertSame(['tag_name' => 'v2.0.0'], $array); + } + + public function testToArrayWithName(): void + { + $params = new UpdateReleaseParams(name: 'Version 2.0'); + + $array = $params->toArray(); + + $this->assertSame(['name' => 'Version 2.0'], $array); + } + + public function testToArrayWithBody(): void + { + $params = new UpdateReleaseParams(body: 'New release notes'); + + $array = $params->toArray(); + + $this->assertSame(['body' => 'New release notes'], $array); + } + + public function testToArrayWithDraftTrue(): void + { + $params = new UpdateReleaseParams(draft: true); + + $array = $params->toArray(); + + $this->assertSame(['draft' => true], $array); + } + + public function testToArrayWithDraftFalse(): void + { + $params = new UpdateReleaseParams(draft: false); + + $array = $params->toArray(); + + $this->assertSame(['draft' => false], $array); + } + + public function testToArrayWithPrereleaseFalse(): void + { + $params = new UpdateReleaseParams(prerelease: false); + + $array = $params->toArray(); + + $this->assertSame(['prerelease' => false], $array); + } + + public function testToArrayWithPrereleaseTrue(): void + { + $params = new UpdateReleaseParams(prerelease: true); + + $array = $params->toArray(); + + $this->assertSame(['prerelease' => true], $array); + } + + public function testToArrayFull(): void + { + $params = new UpdateReleaseParams( + tagName: 'v3.0.0', + name: 'Major Update', + body: 'Complete rewrite', + draft: false, + prerelease: false, + ); + + $array = $params->toArray(); + + $this->assertSame([ + 'tag_name' => 'v3.0.0', + 'name' => 'Major Update', + 'body' => 'Complete rewrite', + 'draft' => false, + 'prerelease' => false, + ], $array); + } + + public function testToArrayWithEmptyStrings(): void + { + $params = new UpdateReleaseParams( + name: '', + body: '', + ); + + $array = $params->toArray(); + + // Empty strings should still be included for updates (to clear fields) + $this->assertSame([ + 'name' => '', + 'body' => '', + ], $array); + } + + public function testPublishDraftRelease(): void + { + $params = new UpdateReleaseParams(draft: false); + + $this->assertFalse($params->isEmpty()); + $this->assertSame(['draft' => false], $params->toArray()); + } + + public function testMarkAsPrerelease(): void + { + $params = new UpdateReleaseParams(prerelease: true); + + $this->assertFalse($params->isEmpty()); + $this->assertSame(['prerelease' => true], $params->toArray()); + } +}