From 2b65096d8befa509e7fe10867788ab1e6edb4640 Mon Sep 17 00:00:00 2001 From: jordan Date: Thu, 1 Jan 2026 15:01:36 -0700 Subject: [PATCH 1/7] ci: replace separate workflows with unified Sentinel Gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove fix-php-code-style-issues.yml - Remove phpstan.yml - Remove run-tests.yml - Add gate.yml with 100% coverage requirement This consolidates CI into a single gate check. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../workflows/fix-php-code-style-issues.yml | 28 --------- .github/workflows/gate.yml | 26 +++++++++ .github/workflows/phpstan.yml | 42 -------------- .github/workflows/run-tests.yml | 58 ------------------- 4 files changed, 26 insertions(+), 128 deletions(-) delete mode 100644 .github/workflows/fix-php-code-style-issues.yml create mode 100644 .github/workflows/gate.yml delete mode 100644 .github/workflows/phpstan.yml delete mode 100644 .github/workflows/run-tests.yml diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml deleted file mode 100644 index fe45c2a..0000000 --- a/.github/workflows/fix-php-code-style-issues.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Fix PHP code style issues - -on: - push: - paths: - - '**.php' - -permissions: - contents: write - -jobs: - php-code-styling: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - - name: Fix PHP code style issues - uses: aglipanci/laravel-pint-action@2.6 - - - name: CommitDTO changes - uses: stefanzweifel/git-auto-commit-action@v7 - with: - commit_message: Fix styling diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..c75cceb --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,26 @@ +name: Sentinel Gate + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + gate: + name: Sentinel Gate + runs-on: ubuntu-latest + permissions: + contents: write + checks: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + - uses: synapse-sentinel/gate@v1 + with: + check: certify + coverage-threshold: 100 + auto-merge: false + coverage-comment: true + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml deleted file mode 100644 index dae87e6..0000000 --- a/.github/workflows/phpstan.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: PHPStan - -on: - push: - paths: - - '**.php' - - 'phpstan.neon.dist' - - '.github/workflows/phpstan.yml' - -jobs: - phpstan: - name: phpstan - runs-on: ubuntu-latest - timeout-minutes: 5 - strategy: - fail-fast: false - matrix: - php: ['8.2', '8.3', '8.4'] - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd - coverage: none - - - name: Install composer dependencies - run: | - # Use Laravel 11 for static analysis since it's more stable with PHPStan - composer require "laravel/framework:^11.0" --no-interaction --no-update - # Allow flexible larastan version for compatibility - composer require --dev "larastan/larastan:^2.0" --no-interaction --no-update - # Fix collision dependency for Laravel compatibility - composer require --dev "nunomaduro/collision:^7.0||^8.0" --no-interaction --no-update - composer update --prefer-stable --prefer-dist --no-interaction - - - name: Run PHPStan - run: ./vendor/bin/phpstan --error-format=github - env: - GITHUB_TOKEN: dummy-token-for-static-analysis diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 56a1b6b..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: run-tests - -on: - push: - paths: - - '**.php' - - '.github/workflows/run-tests.yml' - - 'phpunit.xml.dist' - - 'composer.json' - - 'composer.lock' - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 5 - strategy: - fail-fast: false - matrix: - # Laravel 10 EOL: Feb 4, 2025 - dropped from test matrix - # See: https://laravel.com/docs/12.x/releases#support-policy - php: [8.2, 8.3, 8.4] - laravel: [11.*, 12.*] - stability: [prefer-stable] - include: - - laravel: 12.* - testbench: 10.* - - laravel: 11.* - testbench: 9.* - exclude: - # Laravel 12 requires PHP 8.3+ - - php: 8.2 - laravel: 12.* - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv - tools: composer:v2 - coverage: none - - - name: Install dependencies - run: | - composer remove --dev --no-update larastan/larastan phpstan/phpstan-deprecation-rules phpstan/phpstan-phpunit phpstan/extension-installer - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer require --dev --no-update "nunomaduro/collision:^8.0" - composer update --${{ matrix.stability }} --prefer-dist --no-interaction --with-all-dependencies - - - name: Execute tests - run: vendor/bin/pest - env: - GITHUB_TOKEN: dummy-token-for-testing From 1465397418ba63279cae54e745f2bfb6e718e1b5 Mon Sep 17 00:00:00 2001 From: jordan Date: Thu, 1 Jan 2026 15:04:14 -0700 Subject: [PATCH 2/7] ci: enable auto-merge on gate pass --- .github/workflows/gate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index c75cceb..20140a6 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -21,6 +21,6 @@ jobs: with: check: certify coverage-threshold: 100 - auto-merge: false + auto-merge: true coverage-comment: true github-token: ${{ secrets.GITHUB_TOKEN }} From 675260c6254592df212df1eb14fdb6cab0a444ae Mon Sep 17 00:00:00 2001 From: jordan Date: Thu, 1 Jan 2026 15:04:34 -0700 Subject: [PATCH 3/7] ci: target master branch --- .github/workflows/gate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 20140a6..8e895b5 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -2,9 +2,9 @@ name: Sentinel Gate on: push: - branches: [main] + branches: [master] pull_request: - branches: [main] + branches: [master] jobs: gate: From 95adbbc9fa67fa2e3b8ce53a4bac2d0666599320 Mon Sep 17 00:00:00 2001 From: Agent Bot Date: Thu, 1 Jan 2026 22:22:29 +0000 Subject: [PATCH 4/7] test: add comprehensive test coverage for Requests directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 226 tests covering all 41 request classes in src/Requests/: - Actions: ListWorkflows, GetWorkflowRuns, TriggerWorkflow - Commits: Get, Index - Files: Index - Issues: Index, RepoIndex, Get, Create, Update, Comments, CreateComment, GetComment, UpdateComment, DeleteComment - Pulls: Index, IndexWithSummaryDTO, Get, GetWithDetailDTO, Create, Update, Merge, Comments, CommentsWithFilters, GetComment, CreateComment, UpdateComment, DeleteComment, Files, Reviews, CreateReview - Releases: Index, Get, Latest - RateLimit: Get - Repos: Index, Get, Delete, Search - User Tests verify: - HTTP method configuration - Endpoint resolution - Query parameter handling and filtering - Request body construction - Parameter validation (per_page ranges, SHA formats, ID validation) - DTO response method existence 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/Requests/ActionsRequestsTest.php | 213 ++++++++ tests/Requests/CommitsRequestsTest.php | 132 +++++ tests/Requests/FilesRequestsTest.php | 64 +++ tests/Requests/IssuesRequestsTest.php | 475 +++++++++++++++++ tests/Requests/PullsRequestsTest.php | 641 +++++++++++++++++++++++ tests/Requests/RateLimitRequestsTest.php | 39 ++ tests/Requests/ReleasesRequestsTest.php | 135 +++++ tests/Requests/ReposRequestsTest.php | 264 ++++++++++ tests/Requests/UserRequestsTest.php | 40 ++ 9 files changed, 2003 insertions(+) create mode 100644 tests/Requests/ActionsRequestsTest.php create mode 100644 tests/Requests/CommitsRequestsTest.php create mode 100644 tests/Requests/FilesRequestsTest.php create mode 100644 tests/Requests/IssuesRequestsTest.php create mode 100644 tests/Requests/PullsRequestsTest.php create mode 100644 tests/Requests/RateLimitRequestsTest.php create mode 100644 tests/Requests/ReleasesRequestsTest.php create mode 100644 tests/Requests/ReposRequestsTest.php create mode 100644 tests/Requests/UserRequestsTest.php diff --git a/tests/Requests/ActionsRequestsTest.php b/tests/Requests/ActionsRequestsTest.php new file mode 100644 index 0000000..330e4c4 --- /dev/null +++ b/tests/Requests/ActionsRequestsTest.php @@ -0,0 +1,213 @@ +resolveEndpoint())->toBe('/repos/owner/repo/actions/workflows'); + }); + + it('uses GET method', function () { + $request = new ListWorkflows('owner', 'repo'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts pagination parameters', function () { + $request = new ListWorkflows('owner', 'repo', per_page: 50, page: 2); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 50, 'page' => 2]); + }); + + it('filters null values from query parameters', function () { + $request = new ListWorkflows('owner', 'repo', per_page: 30); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 30]); + expect($query)->not->toHaveKey('page'); + }); + + it('throws exception for per_page less than 1', function () { + new ListWorkflows('owner', 'repo', per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for per_page greater than 100', function () { + new ListWorkflows('owner', 'repo', per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('accepts per_page at boundaries', function () { + $request1 = new ListWorkflows('owner', 'repo', per_page: 1); + $request100 = new ListWorkflows('owner', 'repo', per_page: 100); + + expect($request1)->toBeInstanceOf(ListWorkflows::class); + expect($request100)->toBeInstanceOf(ListWorkflows::class); + }); + }); + + describe('GetWorkflowRuns', function () { + it('constructs with required parameters', function () { + $request = new GetWorkflowRuns('owner', 'repo', 12345); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/actions/workflows/12345/runs'); + }); + + it('uses GET method', function () { + $request = new GetWorkflowRuns('owner', 'repo', 12345); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts all optional parameters', function () { + $request = new GetWorkflowRuns( + 'owner', + 'repo', + 12345, + per_page: 50, + page: 2, + status: 'completed', + conclusion: 'success', + branch: 'main', + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe([ + 'per_page' => 50, + 'page' => 2, + 'status' => 'completed', + 'conclusion' => 'success', + 'branch' => 'main', + ]); + }); + + it('filters null values from query parameters', function () { + $request = new GetWorkflowRuns('owner', 'repo', 12345, status: 'completed'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['status' => 'completed']); + }); + + it('throws exception for per_page less than 1', function () { + new GetWorkflowRuns('owner', 'repo', 12345, per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for per_page greater than 100', function () { + new GetWorkflowRuns('owner', 'repo', 12345, per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('validates status parameter', function () { + new GetWorkflowRuns('owner', 'repo', 12345, status: 'invalid_status'); + })->throws(InvalidArgumentException::class, 'Invalid status provided'); + + it('accepts all valid status values', function () { + $validStatuses = [ + 'completed', 'action_required', 'cancelled', 'failure', 'neutral', + 'skipped', 'stale', 'success', 'timed_out', 'in_progress', 'queued', + 'requested', 'waiting', + ]; + + foreach ($validStatuses as $status) { + $request = new GetWorkflowRuns('owner', 'repo', 12345, status: $status); + expect($request)->toBeInstanceOf(GetWorkflowRuns::class); + } + }); + + it('validates conclusion parameter', function () { + new GetWorkflowRuns('owner', 'repo', 12345, conclusion: 'invalid_conclusion'); + })->throws(InvalidArgumentException::class, 'Invalid conclusion provided'); + + it('accepts all valid conclusion values', function () { + $validConclusions = [ + 'action_required', 'cancelled', 'failure', 'neutral', 'success', + 'skipped', 'stale', 'timed_out', + ]; + + foreach ($validConclusions as $conclusion) { + $request = new GetWorkflowRuns('owner', 'repo', 12345, conclusion: $conclusion); + expect($request)->toBeInstanceOf(GetWorkflowRuns::class); + } + }); + }); + + describe('TriggerWorkflow', function () { + it('constructs with required parameters', function () { + $request = new TriggerWorkflow('owner', 'repo', 12345, ['ref' => 'main']); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/actions/workflows/12345/dispatches'); + }); + + it('uses POST method', function () { + $request = new TriggerWorkflow('owner', 'repo', 12345, ['ref' => 'main']); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::POST); + }); + + it('returns body data from defaultBody', function () { + $data = ['ref' => 'main', 'inputs' => ['env' => 'production']]; + $request = new TriggerWorkflow('owner', 'repo', 12345, $data); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe($data); + }); + + it('throws exception when ref is missing', function () { + new TriggerWorkflow('owner', 'repo', 12345, []); + })->throws(InvalidArgumentException::class, 'The "ref" field is required for workflow dispatch'); + + it('throws exception when ref is empty', function () { + new TriggerWorkflow('owner', 'repo', 12345, ['ref' => '']); + })->throws(InvalidArgumentException::class, 'The "ref" field is required for workflow dispatch'); + + it('throws exception when inputs is not an array', function () { + new TriggerWorkflow('owner', 'repo', 12345, ['ref' => 'main', 'inputs' => 'invalid']); + })->throws(InvalidArgumentException::class, 'The "inputs" field must be an array'); + + it('accepts workflow dispatch without inputs', function () { + $request = new TriggerWorkflow('owner', 'repo', 12345, ['ref' => 'main']); + expect($request)->toBeInstanceOf(TriggerWorkflow::class); + }); + + it('accepts workflow dispatch with empty inputs array', function () { + $request = new TriggerWorkflow('owner', 'repo', 12345, ['ref' => 'main', 'inputs' => []]); + expect($request)->toBeInstanceOf(TriggerWorkflow::class); + }); + }); +}); diff --git a/tests/Requests/CommitsRequestsTest.php b/tests/Requests/CommitsRequestsTest.php new file mode 100644 index 0000000..d0d0580 --- /dev/null +++ b/tests/Requests/CommitsRequestsTest.php @@ -0,0 +1,132 @@ +resolveEndpoint())->toBe('/repos/owner/repo/commits/' . $sha); + }); + + it('uses GET method', function () { + $repo = Repo::fromFullName('owner/repo'); + $sha = 'a' . str_repeat('0', 39); + $request = new Get($repo, $sha); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates commit SHA format - must be 40 hex characters', function () { + $repo = Repo::fromFullName('owner/repo'); + new Get($repo, 'invalid-sha'); + })->throws(InvalidArgumentException::class, 'Invalid commit SHA format'); + + it('validates commit SHA format - rejects short SHA', function () { + $repo = Repo::fromFullName('owner/repo'); + new Get($repo, 'a0b1c2d'); + })->throws(InvalidArgumentException::class, 'Invalid commit SHA format'); + + it('validates commit SHA format - rejects non-hex characters', function () { + $repo = Repo::fromFullName('owner/repo'); + new Get($repo, 'g' . str_repeat('0', 39)); + })->throws(InvalidArgumentException::class, 'Invalid commit SHA format'); + + it('accepts valid 40-character hex SHA', function () { + $repo = Repo::fromFullName('owner/repo'); + $sha = 'abcdef1234567890abcdef1234567890abcdef12'; + $request = new Get($repo, $sha); + + expect($request)->toBeInstanceOf(Get::class); + }); + + it('accepts uppercase hex characters in SHA', function () { + $repo = Repo::fromFullName('owner/repo'); + $sha = 'ABCDEF1234567890ABCDEF1234567890ABCDEF12'; + $request = new Get($repo, $sha); + + expect($request)->toBeInstanceOf(Get::class); + }); + + it('has createDtoFromResponse method', function () { + $repo = Repo::fromFullName('owner/repo'); + $sha = 'a' . str_repeat('0', 39); + $request = new Get($repo, $sha); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Commits\Index', function () { + it('constructs with repo name', function () { + $request = new Index('owner/repo'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/commits'); + }); + + it('uses GET method', function () { + $request = new Index('owner/repo'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts pagination parameters', function () { + $request = new Index('owner/repo', per_page: 50, page: 2); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 50, 'page' => 2]); + }); + + it('filters null values from query parameters', function () { + $request = new Index('owner/repo', per_page: 30); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 30]); + expect($query)->not->toHaveKey('page'); + }); + + it('throws exception for per_page less than 1', function () { + new Index('owner/repo', per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for per_page greater than 100', function () { + new Index('owner/repo', per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('accepts per_page at boundaries', function () { + $request1 = new Index('owner/repo', per_page: 1); + $request100 = new Index('owner/repo', per_page: 100); + + expect($request1)->toBeInstanceOf(Index::class); + expect($request100)->toBeInstanceOf(Index::class); + }); + + it('has createDtoFromResponse method', function () { + $request = new Index('owner/repo'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); +}); diff --git a/tests/Requests/FilesRequestsTest.php b/tests/Requests/FilesRequestsTest.php new file mode 100644 index 0000000..50f9e5f --- /dev/null +++ b/tests/Requests/FilesRequestsTest.php @@ -0,0 +1,64 @@ +resolveEndpoint())->toBe('repos/owner/repo/commits/' . $sha . '/files'); + }); + + it('uses GET method', function () { + $sha = 'a' . str_repeat('0', 39); + $request = new Index('owner/repo', $sha); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates repo name format', function () { + $sha = 'a' . str_repeat('0', 39); + new Index('invalid-repo-name', $sha); + })->throws(InvalidArgumentException::class); + + it('validates commit SHA format - must be 40 hex characters', function () { + new Index('owner/repo', 'invalid-sha'); + })->throws(InvalidArgumentException::class, 'Invalid commit SHA format'); + + it('validates commit SHA format - rejects short SHA', function () { + new Index('owner/repo', 'a0b1c2d'); + })->throws(InvalidArgumentException::class, 'Invalid commit SHA format'); + + it('validates commit SHA format - rejects non-hex characters', function () { + new Index('owner/repo', 'g' . str_repeat('0', 39)); + })->throws(InvalidArgumentException::class, 'Invalid commit SHA format'); + + it('accepts valid 40-character hex SHA', function () { + $sha = 'abcdef1234567890abcdef1234567890abcdef12'; + $request = new Index('owner/repo', $sha); + + expect($request)->toBeInstanceOf(Index::class); + }); + + it('accepts uppercase hex characters in SHA', function () { + $sha = 'ABCDEF1234567890ABCDEF1234567890ABCDEF12'; + $request = new Index('owner/repo', $sha); + + expect($request)->toBeInstanceOf(Index::class); + }); + + it('constructs endpoint correctly with different repo names', function () { + $sha = 'a' . str_repeat('0', 39); + $request = new Index('my-org/my-project', $sha); + + expect($request->resolveEndpoint())->toBe('repos/my-org/my-project/commits/' . $sha . '/files'); + }); + }); +}); diff --git a/tests/Requests/IssuesRequestsTest.php b/tests/Requests/IssuesRequestsTest.php new file mode 100644 index 0000000..0357b49 --- /dev/null +++ b/tests/Requests/IssuesRequestsTest.php @@ -0,0 +1,475 @@ +resolveEndpoint())->toBe('/user/issues'); + }); + + it('uses GET method', function () { + $request = new Index(); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts all optional parameters', function () { + $request = new Index( + per_page: 50, + page: 2, + state: State::OPEN, + labels: 'bug,enhancement', + sort: Sort::CREATED, + direction: Direction::DESC, + assignee: 'testuser', + creator: 'creator', + mentioned: 'mentioned', + since: '2024-01-01T00:00:00Z', + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe([ + 'per_page' => 50, + 'page' => 2, + 'state' => 'open', + 'labels' => 'bug,enhancement', + 'sort' => 'created', + 'direction' => 'desc', + 'assignee' => 'testuser', + 'creator' => 'creator', + 'mentioned' => 'mentioned', + 'since' => '2024-01-01T00:00:00Z', + ]); + }); + + it('filters null values from query parameters', function () { + $request = new Index(per_page: 30, state: State::CLOSED); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 30, 'state' => 'closed']); + }); + + it('throws exception for per_page less than 1', function () { + new Index(per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for per_page greater than 100', function () { + new Index(per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + }); + + describe('Issues\RepoIndex', function () { + it('constructs with required parameters', function () { + $request = new RepoIndex('owner', 'repo'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues'); + }); + + it('uses GET method', function () { + $request = new RepoIndex('owner', 'repo'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts all optional parameters', function () { + $request = new RepoIndex( + 'owner', + 'repo', + per_page: 50, + page: 2, + state: State::ALL, + labels: 'bug', + sort: Sort::UPDATED, + direction: Direction::ASC, + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toHaveKey('per_page', 50); + expect($query)->toHaveKey('state', 'all'); + expect($query)->toHaveKey('sort', 'updated'); + expect($query)->toHaveKey('direction', 'asc'); + }); + + it('throws exception for per_page out of range', function () { + new RepoIndex('owner', 'repo', per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + }); + + describe('Issues\Get', function () { + it('constructs with required parameters', function () { + $request = new Get('owner', 'repo', 42); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/42'); + }); + + it('uses GET method', function () { + $request = new Get('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('throws exception for issue number less than 1', function () { + new Get('owner', 'repo', 0); + })->throws(InvalidArgumentException::class, 'Issue number must be a positive integer'); + + it('throws exception for negative issue number', function () { + new Get('owner', 'repo', -1); + })->throws(InvalidArgumentException::class, 'Issue number must be a positive integer'); + + it('has createDtoFromResponse method', function () { + $request = new Get('owner', 'repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Issues\Create', function () { + it('constructs with required parameters', function () { + $request = new Create('owner', 'repo', 'Test Issue'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues'); + }); + + it('uses POST method', function () { + $request = new Create('owner', 'repo', 'Test Issue'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::POST); + }); + + it('includes all body parameters when provided', function () { + $request = new Create( + 'owner', + 'repo', + 'Test Issue', + bodyText: 'Issue body', + assignees: ['user1', 'user2'], + milestone: 1, + labels: ['bug', 'priority'], + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe([ + 'title' => 'Test Issue', + 'body' => 'Issue body', + 'assignees' => ['user1', 'user2'], + 'milestone' => 1, + 'labels' => ['bug', 'priority'], + ]); + }); + + it('filters null values from body', function () { + $request = new Create('owner', 'repo', 'Test Issue'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe(['title' => 'Test Issue']); + }); + + it('has createDtoFromResponse method', function () { + $request = new Create('owner', 'repo', 'Test'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Issues\Update', function () { + it('constructs with required parameters', function () { + $request = new Update('owner', 'repo', 42); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/42'); + }); + + it('uses PATCH method', function () { + $request = new Update('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::PATCH); + }); + + it('includes all body parameters when provided', function () { + $request = new Update( + 'owner', + 'repo', + 42, + title: 'Updated Title', + bodyText: 'Updated body', + state: State::CLOSED, + assignees: ['user1'], + milestone: 2, + labels: ['fixed'], + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe([ + 'title' => 'Updated Title', + 'body' => 'Updated body', + 'state' => 'closed', + 'assignees' => ['user1'], + 'milestone' => 2, + 'labels' => ['fixed'], + ]); + }); + + it('throws exception for issue number less than 1', function () { + new Update('owner', 'repo', 0); + })->throws(InvalidArgumentException::class, 'Issue number must be a positive integer'); + + it('filters null values from body', function () { + $request = new Update('owner', 'repo', 1, title: 'New Title'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe(['title' => 'New Title']); + }); + }); + + describe('Issues\Comments', function () { + it('constructs with required parameters', function () { + $request = new Comments('owner', 'repo', 42); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/42/comments'); + }); + + it('uses GET method', function () { + $request = new Comments('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts pagination and since parameters', function () { + $request = new Comments('owner', 'repo', 42, per_page: 50, page: 2, since: '2024-01-01T00:00:00Z'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe([ + 'per_page' => 50, + 'page' => 2, + 'since' => '2024-01-01T00:00:00Z', + ]); + }); + + it('throws exception for issue number less than 1', function () { + new Comments('owner', 'repo', 0); + })->throws(InvalidArgumentException::class, 'Issue number must be a positive integer'); + + it('throws exception for per_page out of range', function () { + new Comments('owner', 'repo', 1, per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('has createDtoFromResponse method', function () { + $request = new Comments('owner', 'repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Issues\CreateComment', function () { + it('constructs with required parameters', function () { + $request = new CreateComment('owner', 'repo', 42, 'Comment body'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/42/comments'); + }); + + it('uses POST method', function () { + $request = new CreateComment('owner', 'repo', 1, 'Body'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::POST); + }); + + it('includes body in request body', function () { + $request = new CreateComment('owner', 'repo', 42, 'Comment body'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe(['body' => 'Comment body']); + }); + + it('throws exception for issue number less than 1', function () { + new CreateComment('owner', 'repo', 0, 'Body'); + })->throws(InvalidArgumentException::class, 'Issue number must be a positive integer'); + + it('throws exception for empty body', function () { + $request = new CreateComment('owner', 'repo', 1, ' '); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $method->invoke($request); + })->throws(InvalidArgumentException::class, 'Comment body cannot be empty'); + }); + + describe('Issues\GetComment', function () { + it('constructs with required parameters', function () { + $request = new GetComment('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/comments/123'); + }); + + it('uses GET method', function () { + $request = new GetComment('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('throws exception for comment ID less than 1', function () { + new GetComment('owner', 'repo', 0); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + + it('throws exception for negative comment ID', function () { + new GetComment('owner', 'repo', -5); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + + it('has createDtoFromResponse method', function () { + $request = new GetComment('owner', 'repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Issues\UpdateComment', function () { + it('constructs with required parameters', function () { + $request = new UpdateComment('owner', 'repo', 123, 'Updated body'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/comments/123'); + }); + + it('uses PATCH method', function () { + $request = new UpdateComment('owner', 'repo', 1, 'Body'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::PATCH); + }); + + it('includes body in request body', function () { + $request = new UpdateComment('owner', 'repo', 123, 'Updated body'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe(['body' => 'Updated body']); + }); + + it('throws exception for comment ID less than 1', function () { + new UpdateComment('owner', 'repo', 0, 'Body'); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + + it('throws exception for empty body', function () { + $request = new UpdateComment('owner', 'repo', 1, ''); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $method->invoke($request); + })->throws(InvalidArgumentException::class, 'Comment body cannot be empty'); + }); + + describe('Issues\DeleteComment', function () { + it('constructs with required parameters', function () { + $request = new DeleteComment('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/comments/123'); + }); + + it('uses DELETE method', function () { + $request = new DeleteComment('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::DELETE); + }); + + it('throws exception for comment ID less than 1', function () { + new DeleteComment('owner', 'repo', 0); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + + it('throws exception for negative comment ID', function () { + new DeleteComment('owner', 'repo', -1); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + }); +}); diff --git a/tests/Requests/PullsRequestsTest.php b/tests/Requests/PullsRequestsTest.php new file mode 100644 index 0000000..3461418 --- /dev/null +++ b/tests/Requests/PullsRequestsTest.php @@ -0,0 +1,641 @@ +resolveEndpoint())->toBe('repos/owner/repo/pulls'); + }); + + it('uses GET method', function () { + $request = new Index('owner/repo'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates repo name format', function () { + new Index('invalid-repo'); + })->throws(InvalidArgumentException::class); + + it('accepts parameters array', function () { + $request = new Index('owner/repo', ['state' => 'open', 'per_page' => 50]); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toHaveKey('state'); + expect($query)->toHaveKey('per_page'); + }); + + it('has createDtoFromResponse method', function () { + $request = new Index('owner/repo'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\Get', function () { + it('constructs with required parameters', function () { + $request = new Get('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42'); + }); + + it('uses GET method', function () { + $request = new Get('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates repo name format', function () { + new Get('invalid-repo', 1); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new Get('owner/repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\Create', function () { + it('constructs with required parameters', function () { + $request = new Create('owner/repo', 'Title', 'feature-branch', 'main'); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls'); + }); + + it('uses POST method', function () { + $request = new Create('owner/repo', 'Title', 'head', 'base'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::POST); + }); + + it('includes all body parameters', function () { + $request = new Create( + 'owner/repo', + 'PR Title', + 'feature-branch', + 'main', + bodyText: 'PR description', + draft: true, + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe([ + 'title' => 'PR Title', + 'head' => 'feature-branch', + 'base' => 'main', + 'body' => 'PR description', + 'draft' => true, + ]); + }); + + it('validates repo name format', function () { + new Create('invalid', 'Title', 'head', 'base'); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new Create('owner/repo', 'Title', 'head', 'base'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\Update', function () { + it('constructs with required parameters', function () { + $request = new Update('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42'); + }); + + it('uses PATCH method', function () { + $request = new Update('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::PATCH); + }); + + it('includes parameters in body', function () { + $request = new Update('owner/repo', 42, ['title' => 'New Title', 'state' => 'closed']); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe(['title' => 'New Title', 'state' => 'closed']); + }); + + it('validates repo name format', function () { + new Update('invalid', 1); + })->throws(InvalidArgumentException::class); + }); + + describe('Pulls\Merge', function () { + it('constructs with required parameters', function () { + $request = new Merge('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42/merge'); + }); + + it('uses PUT method', function () { + $request = new Merge('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::PUT); + }); + + it('includes all body parameters', function () { + $request = new Merge( + 'owner/repo', + 42, + commitMessage: 'Merge commit message', + sha: 'abc123', + mergeMethod: MergeMethod::Squash, + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe([ + 'commit_message' => 'Merge commit message', + 'sha' => 'abc123', + 'merge_method' => 'squash', + ]); + }); + + it('uses merge method as default', function () { + $request = new Merge('owner/repo', 42); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toHaveKey('merge_method', 'merge'); + }); + + it('supports all merge methods', function () { + $mergeMethods = [MergeMethod::Merge, MergeMethod::Squash, MergeMethod::Rebase]; + + foreach ($mergeMethods as $mergeMethod) { + $request = new Merge('owner/repo', 42, mergeMethod: $mergeMethod); + expect($request)->toBeInstanceOf(Merge::class); + } + }); + + it('validates repo name format', function () { + new Merge('invalid', 1); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new Merge('owner/repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\Comments', function () { + it('constructs with required parameters', function () { + $request = new Comments('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42/comments'); + }); + + it('uses GET method', function () { + $request = new Comments('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates repo name format', function () { + new Comments('invalid', 1); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new Comments('owner/repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\GetComment', function () { + it('constructs with required parameters', function () { + $request = new GetComment('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/comments/123'); + }); + + it('uses GET method', function () { + $request = new GetComment('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('throws exception for comment ID less than 1', function () { + new GetComment('owner', 'repo', 0); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + + it('throws exception for negative comment ID', function () { + new GetComment('owner', 'repo', -5); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + + it('has createDtoFromResponse method', function () { + $request = new GetComment('owner', 'repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\CreateComment', function () { + it('constructs with required parameters', function () { + $request = new CreateComment('owner/repo', 42, 'Comment body', 'abc123', 'file.php', 10); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42/comments'); + }); + + it('uses POST method', function () { + $request = new CreateComment('owner/repo', 1, 'Body', 'sha', 'file', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::POST); + }); + + it('includes all body parameters', function () { + $request = new CreateComment('owner/repo', 42, 'Comment body', 'abc123', 'file.php', 10); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe([ + 'body' => 'Comment body', + 'commit_id' => 'abc123', + 'path' => 'file.php', + 'position' => 10, + ]); + }); + + it('validates repo name format', function () { + new CreateComment('invalid', 1, 'Body', 'sha', 'file', 1); + })->throws(InvalidArgumentException::class); + }); + + describe('Pulls\UpdateComment', function () { + it('constructs with required parameters', function () { + $request = new UpdateComment('owner', 'repo', 123, 'Updated body'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/comments/123'); + }); + + it('uses PATCH method', function () { + $request = new UpdateComment('owner', 'repo', 1, 'Body'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::PATCH); + }); + + it('includes body in request body', function () { + $request = new UpdateComment('owner', 'repo', 123, 'Updated body'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe(['body' => 'Updated body']); + }); + + it('throws exception for comment ID less than 1', function () { + new UpdateComment('owner', 'repo', 0, 'Body'); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + + it('throws exception for empty body', function () { + $request = new UpdateComment('owner', 'repo', 1, ''); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $method->invoke($request); + })->throws(InvalidArgumentException::class, 'Comment body cannot be empty'); + }); + + describe('Pulls\DeleteComment', function () { + it('constructs with required parameters', function () { + $request = new DeleteComment('owner', 'repo', 123); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/comments/123'); + }); + + it('uses DELETE method', function () { + $request = new DeleteComment('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::DELETE); + }); + + it('throws exception for comment ID less than 1', function () { + new DeleteComment('owner', 'repo', 0); + })->throws(InvalidArgumentException::class, 'Comment ID must be a positive integer'); + }); + + describe('Pulls\Files', function () { + it('constructs with required parameters', function () { + $request = new Files('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42/files'); + }); + + it('uses GET method', function () { + $request = new Files('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates repo name format', function () { + new Files('invalid', 1); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new Files('owner/repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\Reviews', function () { + it('constructs with required parameters', function () { + $request = new Reviews('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42/reviews'); + }); + + it('uses GET method', function () { + $request = new Reviews('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates repo name format', function () { + new Reviews('invalid', 1); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new Reviews('owner/repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\CreateReview', function () { + it('constructs with required parameters', function () { + $request = new CreateReview('owner/repo', 42, 'Review body'); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42/reviews'); + }); + + it('uses POST method', function () { + $request = new CreateReview('owner/repo', 1, 'Body'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::POST); + }); + + it('includes all body parameters', function () { + $comments = [['path' => 'file.php', 'position' => 5, 'body' => 'Comment']]; + $request = new CreateReview( + 'owner/repo', + 42, + 'Review body', + event: 'APPROVE', + comments: $comments, + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body)->toBe([ + 'body' => 'Review body', + 'event' => 'APPROVE', + 'comments' => $comments, + ]); + }); + + it('uses COMMENT as default event', function () { + $request = new CreateReview('owner/repo', 42, 'Body'); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultBody'); + $method->setAccessible(true); + $body = $method->invoke($request); + + expect($body['event'])->toBe('COMMENT'); + }); + + it('validates repo name format', function () { + new CreateReview('invalid', 1, 'Body'); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new CreateReview('owner/repo', 1, 'Body'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\CommentsWithFilters', function () { + it('constructs with required parameters', function () { + $request = new CommentsWithFilters('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42/comments'); + }); + + it('uses GET method', function () { + $request = new CommentsWithFilters('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts filter parameters', function () { + $request = new CommentsWithFilters('owner/repo', 42, ['per_page' => 50, 'page' => 2]); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 50, 'page' => 2]); + }); + + it('limits per_page to 100', function () { + $request = new CommentsWithFilters('owner/repo', 42, ['per_page' => 150]); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query['per_page'])->toBe(100); + }); + + it('validates repo name format', function () { + new CommentsWithFilters('invalid', 1); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new CommentsWithFilters('owner/repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\GetWithDetailDTO', function () { + it('constructs with required parameters', function () { + $request = new GetWithDetailDTO('owner/repo', 42); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls/42'); + }); + + it('uses GET method', function () { + $request = new GetWithDetailDTO('owner/repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('validates repo name format', function () { + new GetWithDetailDTO('invalid', 1); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new GetWithDetailDTO('owner/repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Pulls\IndexWithSummaryDTO', function () { + it('constructs with required parameters', function () { + $request = new IndexWithSummaryDTO('owner/repo'); + + expect($request->resolveEndpoint())->toBe('repos/owner/repo/pulls'); + }); + + it('uses GET method', function () { + $request = new IndexWithSummaryDTO('owner/repo'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts parameters array', function () { + $request = new IndexWithSummaryDTO('owner/repo', ['state' => 'open', 'per_page' => 50]); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toHaveKey('state'); + expect($query)->toHaveKey('per_page'); + }); + + it('validates repo name format', function () { + new IndexWithSummaryDTO('invalid'); + })->throws(InvalidArgumentException::class); + + it('has createDtoFromResponse method', function () { + $request = new IndexWithSummaryDTO('owner/repo'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); +}); diff --git a/tests/Requests/RateLimitRequestsTest.php b/tests/Requests/RateLimitRequestsTest.php new file mode 100644 index 0000000..c1b0a3e --- /dev/null +++ b/tests/Requests/RateLimitRequestsTest.php @@ -0,0 +1,39 @@ +resolveEndpoint())->toBe('/rate_limit'); + }); + + it('uses GET method', function () { + $request = new Get(); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('has createDtoFromResponse method', function () { + $request = new Get(); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + + it('can be instantiated multiple times', function () { + $request1 = new Get(); + $request2 = new Get(); + + expect($request1)->toBeInstanceOf(Get::class); + expect($request2)->toBeInstanceOf(Get::class); + expect($request1)->not->toBe($request2); + }); + }); +}); diff --git a/tests/Requests/ReleasesRequestsTest.php b/tests/Requests/ReleasesRequestsTest.php new file mode 100644 index 0000000..d1df078 --- /dev/null +++ b/tests/Requests/ReleasesRequestsTest.php @@ -0,0 +1,135 @@ +resolveEndpoint())->toBe('/repos/owner/repo/releases'); + }); + + it('uses GET method', function () { + $request = new Index('owner', 'repo'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts pagination parameters', function () { + $request = new Index('owner', 'repo', per_page: 50, page: 2); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 50, 'page' => 2]); + }); + + it('filters null values from query parameters', function () { + $request = new Index('owner', 'repo', per_page: 30); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 30]); + expect($query)->not->toHaveKey('page'); + }); + + it('throws exception for per_page less than 1', function () { + new Index('owner', 'repo', per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for per_page greater than 100', function () { + new Index('owner', 'repo', per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('accepts per_page at boundaries', function () { + $request1 = new Index('owner', 'repo', per_page: 1); + $request100 = new Index('owner', 'repo', per_page: 100); + + expect($request1)->toBeInstanceOf(Index::class); + expect($request100)->toBeInstanceOf(Index::class); + }); + + it('has createDtoFromResponse method', function () { + $request = new Index('owner', 'repo'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Releases\Get', function () { + it('constructs with required parameters', function () { + $request = new Get('owner', 'repo', 12345); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/releases/12345'); + }); + + it('uses GET method', function () { + $request = new Get('owner', 'repo', 1); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('constructs endpoint with different release IDs', function () { + $request1 = new Get('owner', 'repo', 1); + $request2 = new Get('owner', 'repo', 999999); + + expect($request1->resolveEndpoint())->toBe('/repos/owner/repo/releases/1'); + expect($request2->resolveEndpoint())->toBe('/repos/owner/repo/releases/999999'); + }); + + it('has createDtoFromResponse method', function () { + $request = new Get('owner', 'repo', 1); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Releases\Latest', function () { + it('constructs with required parameters', function () { + $request = new Latest('owner', 'repo'); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo/releases/latest'); + }); + + it('uses GET method', function () { + $request = new Latest('owner', 'repo'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('constructs endpoint with different repos', function () { + $request1 = new Latest('org1', 'project1'); + $request2 = new Latest('org2', 'project2'); + + expect($request1->resolveEndpoint())->toBe('/repos/org1/project1/releases/latest'); + expect($request2->resolveEndpoint())->toBe('/repos/org2/project2/releases/latest'); + }); + + it('has createDtoFromResponse method', function () { + $request = new Latest('owner', 'repo'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); +}); diff --git a/tests/Requests/ReposRequestsTest.php b/tests/Requests/ReposRequestsTest.php new file mode 100644 index 0000000..dca0445 --- /dev/null +++ b/tests/Requests/ReposRequestsTest.php @@ -0,0 +1,264 @@ +resolveEndpoint())->toBe('/user/repos'); + }); + + it('uses GET method', function () { + $request = new Index(); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts all optional parameters', function () { + $request = new Index( + per_page: 50, + page: 2, + visibility: Visibility::PUBLIC, + sort: Sort::CREATED, + direction: Direction::DESC, + type: Type::Owner, + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe([ + 'per_page' => 50, + 'page' => 2, + 'visibility' => 'public', + 'sort' => 'created', + 'direction' => 'desc', + 'type' => 'owner', + ]); + }); + + it('filters null values from query parameters', function () { + $request = new Index(per_page: 30, visibility: Visibility::PRIVATE); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['per_page' => 30, 'visibility' => 'private']); + }); + + it('throws exception for per_page less than 1', function () { + new Index(per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for per_page greater than 100', function () { + new Index(per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('accepts per_page at boundaries', function () { + $request1 = new Index(per_page: 1); + $request100 = new Index(per_page: 100); + + expect($request1)->toBeInstanceOf(Index::class); + expect($request100)->toBeInstanceOf(Index::class); + }); + + it('supports all visibility options', function () { + $visibilities = [Visibility::PUBLIC, Visibility::PRIVATE, Visibility::INTERNAL]; + + foreach ($visibilities as $visibility) { + $request = new Index(visibility: $visibility); + expect($request)->toBeInstanceOf(Index::class); + } + }); + + it('supports all type options', function () { + $types = [Type::All, Type::Owner, Type::Public, Type::Private, Type::Member, Type::Forks, Type::Sources]; + + foreach ($types as $type) { + $request = new Index(type: $type); + expect($request)->toBeInstanceOf(Index::class); + } + }); + + it('has createDtoFromResponse method', function () { + $request = new Index(); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Repos\Get', function () { + it('constructs with repo value object', function () { + $repo = Repo::fromFullName('owner/repo'); + $request = new Get($repo); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo'); + }); + + it('uses GET method', function () { + $repo = Repo::fromFullName('owner/repo'); + $request = new Get($repo); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('constructs endpoint correctly with different repos', function () { + $repo1 = Repo::fromFullName('org1/project1'); + $repo2 = Repo::fromFullName('org2/project2'); + + $request1 = new Get($repo1); + $request2 = new Get($repo2); + + expect($request1->resolveEndpoint())->toBe('/repos/org1/project1'); + expect($request2->resolveEndpoint())->toBe('/repos/org2/project2'); + }); + + it('has createDtoFromResponse method', function () { + $repo = Repo::fromFullName('owner/repo'); + $request = new Get($repo); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); + + describe('Repos\Delete', function () { + it('constructs with repo value object', function () { + $repo = Repo::fromFullName('owner/repo'); + $request = new Delete($repo); + + expect($request->resolveEndpoint())->toBe('/repos/owner/repo'); + }); + + it('uses DELETE method', function () { + $repo = Repo::fromFullName('owner/repo'); + $request = new Delete($repo); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::DELETE); + }); + + it('constructs endpoint correctly with different repos', function () { + $repo1 = Repo::fromFullName('org1/project1'); + $repo2 = Repo::fromFullName('org2/project2'); + + $request1 = new Delete($repo1); + $request2 = new Delete($repo2); + + expect($request1->resolveEndpoint())->toBe('/repos/org1/project1'); + expect($request2->resolveEndpoint())->toBe('/repos/org2/project2'); + }); + }); + + describe('Repos\Search', function () { + it('constructs with required parameters', function () { + $request = new Search('laravel'); + + expect($request->resolveEndpoint())->toBe('/search/repositories'); + }); + + it('uses GET method', function () { + $request = new Search('test'); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('accepts all optional parameters', function () { + $request = new Search( + 'laravel', + sort: 'stars', + order: Direction::DESC, + per_page: 50, + page: 2, + ); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe([ + 'q' => 'laravel', + 'sort' => 'stars', + 'order' => 'desc', + 'per_page' => 50, + 'page' => 2, + ]); + }); + + it('filters null values from query parameters', function () { + $request = new Search('test', per_page: 30); + + $reflection = new ReflectionClass($request); + $method = $reflection->getMethod('defaultQuery'); + $method->setAccessible(true); + $query = $method->invoke($request); + + expect($query)->toBe(['q' => 'test', 'per_page' => 30]); + }); + + it('throws exception for per_page less than 1', function () { + new Search('test', per_page: 0); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for per_page greater than 100', function () { + new Search('test', per_page: 101); + })->throws(InvalidArgumentException::class, 'Per page must be between 1 and 100'); + + it('throws exception for invalid sort value', function () { + new Search('test', sort: 'invalid'); + })->throws(InvalidArgumentException::class, 'Sort must be one of: stars, forks, help-wanted-issues, updated'); + + it('accepts valid sort values', function () { + $validSorts = ['stars', 'forks', 'help-wanted-issues', 'updated']; + + foreach ($validSorts as $sort) { + $request = new Search('test', sort: $sort); + expect($request)->toBeInstanceOf(Search::class); + } + }); + + it('accepts both order directions', function () { + $request1 = new Search('test', order: Direction::ASC); + $request2 = new Search('test', order: Direction::DESC); + + expect($request1)->toBeInstanceOf(Search::class); + expect($request2)->toBeInstanceOf(Search::class); + }); + + it('has createDtoFromResponse method', function () { + $request = new Search('test'); + + expect(method_exists($request, 'createDtoFromResponse'))->toBeTrue(); + }); + }); +}); diff --git a/tests/Requests/UserRequestsTest.php b/tests/Requests/UserRequestsTest.php new file mode 100644 index 0000000..4aa5193 --- /dev/null +++ b/tests/Requests/UserRequestsTest.php @@ -0,0 +1,40 @@ +resolveEndpoint())->toBe('/user'); + }); + + it('uses GET method', function () { + $request = new User(); + + $reflection = new ReflectionClass($request); + $property = $reflection->getProperty('method'); + $property->setAccessible(true); + + expect($property->getValue($request))->toBe(Method::GET); + }); + + it('can be instantiated multiple times', function () { + $request1 = new User(); + $request2 = new User(); + + expect($request1)->toBeInstanceOf(User::class); + expect($request2)->toBeInstanceOf(User::class); + expect($request1)->not->toBe($request2); + }); + + it('returns consistent endpoint', function () { + $request = new User(); + + expect($request->resolveEndpoint())->toBe('/user'); + expect($request->resolveEndpoint())->toBe('/user'); + }); + }); +}); From a8d355deeb09986b5bc294e2d17ec9097e7ab013 Mon Sep 17 00:00:00 2001 From: Agent Bot Date: Thu, 1 Jan 2026 22:32:20 +0000 Subject: [PATCH 5/7] test: add comprehensive test coverage for Data DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 223 new tests covering all 27 DTO files in src/Data/ directory: Commits DTOs: - CommitAuthorDataTest.php (3 tests) - CommitStatsDataTest.php (3 tests) - CommitFileDataTest.php (3 tests) - CommitDetailsDataTest.php (4 tests) - CommitDataTest.php (6 tests) Issues DTOs: - IssueDTOTest.php (7 tests) - IssueCommentDTOTest.php (4 tests) - LabelDTOTest.php (5 tests) Pulls DTOs: - PullRequestDTOTest.php (6 tests) - PullRequestCommentDTOTest.php (6 tests) - PullRequestFileDTOTest.php (27 tests) - PullRequestReviewDTOTest.php (7 tests) - CommentMetadataTest.php (19 tests) - MergeResponseDTOTest.php (4 tests) - PullRequestSummaryDTOTest.php (6 tests) - PullRequestDetailDTOTest.php (23 tests) - PullRequestDTOFactoryTest.php (9 tests) - ParamsTest.php (7 tests) Repos DTOs: - RepoDataTest.php (12 tests, moved and enhanced) - LicenseDataTest.php (4 tests) - SearchRepositoriesDataTest.php (6 tests) Releases DTOs: - ReleaseDataTest.php (11 tests) Root-level DTOs: - FileDTOTest.php (7 tests) - TreeDataTest.php (4 tests) - VerificationDataTest.php (6 tests) - RateLimitDTOTest.php (14 tests) - GitUserDataTest.php (9 additional tests) All 668 tests pass with 1856 assertions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Data/Commits/CommitAuthorDataTest.php | 54 ++++ tests/Unit/Data/Commits/CommitDataTest.php | 140 ++++++++++ .../Data/Commits/CommitDetailsDataTest.php | 92 +++++++ .../Unit/Data/Commits/CommitFileDataTest.php | 74 ++++++ .../Unit/Data/Commits/CommitStatsDataTest.php | 47 ++++ tests/Unit/Data/FileDTOTest.php | 129 +++++++++ tests/Unit/Data/GitUserDataTest.php | 195 ++++++++++++++ .../Unit/Data/Issues/IssueCommentDTOTest.php | 59 +++++ tests/Unit/Data/Issues/IssueDTOTest.php | 134 ++++++++++ tests/Unit/Data/Issues/LabelDTOTest.php | 82 ++++++ tests/Unit/Data/Pulls/CommentMetadataTest.php | 171 ++++++++++++ .../Unit/Data/Pulls/MergeResponseDTOTest.php | 57 ++++ tests/Unit/Data/Pulls/ParamsTest.php | 116 ++++++++ .../Data/Pulls/PullRequestCommentDTOTest.php | 93 +++++++ .../Data/Pulls/PullRequestDTOFactoryTest.php | 118 +++++++++ tests/Unit/Data/Pulls/PullRequestDTOTest.php | 141 ++++++++++ .../Data/Pulls/PullRequestDetailDTOTest.php | 250 ++++++++++++++++++ .../Data/Pulls/PullRequestFileDTOTest.php | 245 +++++++++++++++++ .../Data/Pulls/PullRequestReviewDTOTest.php | 90 +++++++ .../Data/Pulls/PullRequestSummaryDTOTest.php | 110 ++++++++ tests/Unit/Data/RateLimitDTOTest.php | 210 +++++++++++++++ tests/Unit/Data/Releases/ReleaseDataTest.php | 163 ++++++++++++ tests/Unit/Data/Repos/LicenseDataTest.php | 68 +++++ tests/Unit/Data/{ => Repos}/RepoDataTest.php | 101 +++++++ .../Data/Repos/SearchRepositoriesDataTest.php | 98 +++++++ tests/Unit/Data/TreeDataTest.php | 58 ++++ tests/Unit/Data/VerificationDataTest.php | 110 ++++++++ 27 files changed, 3205 insertions(+) create mode 100644 tests/Unit/Data/Commits/CommitAuthorDataTest.php create mode 100644 tests/Unit/Data/Commits/CommitDataTest.php create mode 100644 tests/Unit/Data/Commits/CommitDetailsDataTest.php create mode 100644 tests/Unit/Data/Commits/CommitFileDataTest.php create mode 100644 tests/Unit/Data/Commits/CommitStatsDataTest.php create mode 100644 tests/Unit/Data/FileDTOTest.php create mode 100644 tests/Unit/Data/Issues/IssueCommentDTOTest.php create mode 100644 tests/Unit/Data/Issues/IssueDTOTest.php create mode 100644 tests/Unit/Data/Issues/LabelDTOTest.php create mode 100644 tests/Unit/Data/Pulls/CommentMetadataTest.php create mode 100644 tests/Unit/Data/Pulls/MergeResponseDTOTest.php create mode 100644 tests/Unit/Data/Pulls/ParamsTest.php create mode 100644 tests/Unit/Data/Pulls/PullRequestCommentDTOTest.php create mode 100644 tests/Unit/Data/Pulls/PullRequestDTOFactoryTest.php create mode 100644 tests/Unit/Data/Pulls/PullRequestDTOTest.php create mode 100644 tests/Unit/Data/Pulls/PullRequestDetailDTOTest.php create mode 100644 tests/Unit/Data/Pulls/PullRequestFileDTOTest.php create mode 100644 tests/Unit/Data/Pulls/PullRequestReviewDTOTest.php create mode 100644 tests/Unit/Data/Pulls/PullRequestSummaryDTOTest.php create mode 100644 tests/Unit/Data/RateLimitDTOTest.php create mode 100644 tests/Unit/Data/Releases/ReleaseDataTest.php create mode 100644 tests/Unit/Data/Repos/LicenseDataTest.php rename tests/Unit/Data/{ => Repos}/RepoDataTest.php (81%) create mode 100644 tests/Unit/Data/Repos/SearchRepositoriesDataTest.php create mode 100644 tests/Unit/Data/TreeDataTest.php create mode 100644 tests/Unit/Data/VerificationDataTest.php diff --git a/tests/Unit/Data/Commits/CommitAuthorDataTest.php b/tests/Unit/Data/Commits/CommitAuthorDataTest.php new file mode 100644 index 0000000..ce229ba --- /dev/null +++ b/tests/Unit/Data/Commits/CommitAuthorDataTest.php @@ -0,0 +1,54 @@ + 'John Doe', + 'email' => 'john@example.com', + 'date' => '2024-01-15T10:30:00Z', + ]; + + $author = CommitAuthorData::fromArray($data); + + expect($author->name)->toBe('John Doe'); + expect($author->email)->toBe('john@example.com'); + expect($author->date)->toBeInstanceOf(Carbon::class); + expect($author->date->toISOString())->toBe('2024-01-15T10:30:00.000000Z'); +}); + +it('can convert CommitAuthorData to array', function () { + $date = Carbon::parse('2024-01-15T10:30:00Z'); + + $author = new CommitAuthorData( + name: 'Jane Doe', + email: 'jane@example.com', + date: $date, + ); + + $array = $author->toArray(); + + expect($array['name'])->toBe('Jane Doe'); + expect($array['email'])->toBe('jane@example.com'); + expect($array['date'])->toBe($date->toISOString()); +}); + +it('parses different date formats correctly', function () { + $dates = [ + '2024-01-15T10:30:00Z', + '2024-01-15 10:30:00', + '2024-01-15', + ]; + + foreach ($dates as $dateString) { + $data = [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'date' => $dateString, + ]; + + $author = CommitAuthorData::fromArray($data); + expect($author->date)->toBeInstanceOf(Carbon::class); + } +}); diff --git a/tests/Unit/Data/Commits/CommitDataTest.php b/tests/Unit/Data/Commits/CommitDataTest.php new file mode 100644 index 0000000..5290a32 --- /dev/null +++ b/tests/Unit/Data/Commits/CommitDataTest.php @@ -0,0 +1,140 @@ +sampleData = [ + 'sha' => 'abc123def456789', + 'node_id' => 'C_commit123', + 'commit' => [ + 'author' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'date' => '2024-01-15T10:30:00Z', + ], + 'committer' => [ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'date' => '2024-01-15T10:35:00Z', + ], + 'message' => 'Fix bug in authentication', + 'tree' => [ + 'sha' => 'tree123sha', + 'url' => 'https://api.github.com/repos/owner/repo/git/trees/tree123sha', + ], + 'url' => 'https://api.github.com/repos/owner/repo/git/commits/abc123', + 'comment_count' => 5, + 'verification' => [ + 'verified' => true, + 'reason' => 'valid', + 'signature' => 'gpg-signature', + 'payload' => 'commit-payload', + 'verified_at' => null, + ], + ], + 'url' => 'https://api.github.com/repos/owner/repo/commits/abc123', + 'html_url' => 'https://github.com/owner/repo/commit/abc123', + 'comments_url' => 'https://api.github.com/repos/owner/repo/commits/abc123/comments', + 'author' => $this->createMockUserData('johndoe', 1), + 'committer' => $this->createMockUserData('janedoe', 2), + 'parents' => [ + ['sha' => 'parent123', 'url' => 'https://api.github.com/repos/owner/repo/commits/parent123'], + ], + ]; +}); + +it('can create CommitData from array', function () { + $commit = CommitData::fromArray($this->sampleData); + + expect($commit->sha)->toBe('abc123def456789'); + expect($commit->node_id)->toBe('C_commit123'); + expect($commit->commit)->toBeInstanceOf(CommitDetailsData::class); + expect($commit->commit->message)->toBe('Fix bug in authentication'); + expect($commit->url)->toBe('https://api.github.com/repos/owner/repo/commits/abc123'); + expect($commit->html_url)->toBe('https://github.com/owner/repo/commit/abc123'); + expect($commit->comments_url)->toBe('https://api.github.com/repos/owner/repo/commits/abc123/comments'); + expect($commit->author)->toBeInstanceOf(GitUserData::class); + expect($commit->author->login)->toBe('johndoe'); + expect($commit->committer)->toBeInstanceOf(GitUserData::class); + expect($commit->committer->login)->toBe('janedoe'); + expect($commit->parents)->toBeArray(); + expect($commit->parents)->toHaveCount(1); +}); + +it('can convert CommitData to array', function () { + $commit = CommitData::fromArray($this->sampleData); + $array = $commit->toArray(); + + expect($array['sha'])->toBe('abc123def456789'); + expect($array['node_id'])->toBe('C_commit123'); + expect($array['commit'])->toBeArray(); + expect($array['commit']['message'])->toBe('Fix bug in authentication'); + expect($array['author']['login'])->toBe('johndoe'); + expect($array['committer']['login'])->toBe('janedoe'); + expect($array['parents'])->toHaveCount(1); +}); + +it('handles null author and committer', function () { + $dataWithNullUsers = $this->sampleData; + unset($dataWithNullUsers['author']); + unset($dataWithNullUsers['committer']); + + $commit = CommitData::fromArray($dataWithNullUsers); + + expect($commit->author)->toBeNull(); + expect($commit->committer)->toBeNull(); +}); + +it('handles stats when present', function () { + $dataWithStats = array_merge($this->sampleData, [ + 'stats' => [ + 'total' => 150, + 'additions' => 100, + 'deletions' => 50, + ], + ]); + + $commit = CommitData::fromArray($dataWithStats); + + expect($commit->stats)->toBeInstanceOf(CommitStatsData::class); + expect($commit->stats->total)->toBe(150); + expect($commit->stats->additions)->toBe(100); + expect($commit->stats->deletions)->toBe(50); +}); + +it('handles files array when present', function () { + $dataWithFiles = array_merge($this->sampleData, [ + 'files' => [ + [ + 'filename' => 'src/Example.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + 'blob_url' => 'https://github.com/blob', + 'raw_url' => 'https://github.com/raw', + 'contents_url' => 'https://api.github.com/contents', + ], + ], + ]); + + $commit = CommitData::fromArray($dataWithFiles); + + expect($commit->files)->toBeArray(); + expect($commit->files)->toHaveCount(1); + expect($commit->files[0])->toBeInstanceOf(CommitFileData::class); + expect($commit->files[0]->filename)->toBe('src/Example.php'); +}); + +it('handles empty parents array', function () { + $dataWithNoParents = $this->sampleData; + unset($dataWithNoParents['parents']); + + $commit = CommitData::fromArray($dataWithNoParents); + + expect($commit->parents)->toBe([]); +}); diff --git a/tests/Unit/Data/Commits/CommitDetailsDataTest.php b/tests/Unit/Data/Commits/CommitDetailsDataTest.php new file mode 100644 index 0000000..eeecfde --- /dev/null +++ b/tests/Unit/Data/Commits/CommitDetailsDataTest.php @@ -0,0 +1,92 @@ +sampleData = [ + 'author' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'date' => '2024-01-15T10:30:00Z', + ], + 'committer' => [ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'date' => '2024-01-15T10:35:00Z', + ], + 'message' => 'Fix bug in authentication', + 'tree' => [ + 'sha' => 'tree123sha', + 'url' => 'https://api.github.com/repos/owner/repo/git/trees/tree123sha', + ], + 'url' => 'https://api.github.com/repos/owner/repo/git/commits/abc123', + 'comment_count' => 5, + 'verification' => [ + 'verified' => true, + 'reason' => 'valid', + 'signature' => 'gpg-signature', + 'payload' => 'commit-payload', + 'verified_at' => '2024-01-15T10:30:00Z', + ], + ]; +}); + +it('can create CommitDetailsData from array', function () { + $details = CommitDetailsData::fromArray($this->sampleData); + + expect($details->author)->toBeInstanceOf(CommitAuthorData::class); + expect($details->author->name)->toBe('John Doe'); + expect($details->committer)->toBeInstanceOf(CommitAuthorData::class); + expect($details->committer->name)->toBe('Jane Doe'); + expect($details->message)->toBe('Fix bug in authentication'); + expect($details->tree)->toBeInstanceOf(TreeData::class); + expect($details->tree->sha)->toBe('tree123sha'); + expect($details->url)->toBe('https://api.github.com/repos/owner/repo/git/commits/abc123'); + expect($details->comment_count)->toBe(5); + expect($details->verification)->toBeInstanceOf(VerificationData::class); + expect($details->verification->verified)->toBeTrue(); +}); + +it('can convert CommitDetailsData to array', function () { + $details = CommitDetailsData::fromArray($this->sampleData); + $array = $details->toArray(); + + expect($array['author']['name'])->toBe('John Doe'); + expect($array['committer']['name'])->toBe('Jane Doe'); + expect($array['message'])->toBe('Fix bug in authentication'); + expect($array['tree']['sha'])->toBe('tree123sha'); + expect($array['comment_count'])->toBe(5); + expect($array['verification']['verified'])->toBeTrue(); +}); + +it('handles files array when present', function () { + $dataWithFiles = array_merge($this->sampleData, [ + 'files' => [ + [ + 'sha' => 'file123', + 'filename' => 'src/Example.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + ], + ], + ]); + + $details = CommitDetailsData::fromArray($dataWithFiles); + + expect($details->files)->toBeArray(); + expect($details->files)->toHaveCount(1); + expect($details->files[0])->toBeInstanceOf(FileDTO::class); + expect($details->files[0]->filename)->toBe('src/Example.php'); +}); + +it('handles null files array', function () { + $details = CommitDetailsData::fromArray($this->sampleData); + + expect($details->files)->toBeNull(); +}); diff --git a/tests/Unit/Data/Commits/CommitFileDataTest.php b/tests/Unit/Data/Commits/CommitFileDataTest.php new file mode 100644 index 0000000..717ef16 --- /dev/null +++ b/tests/Unit/Data/Commits/CommitFileDataTest.php @@ -0,0 +1,74 @@ + 'src/Example.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + 'blob_url' => 'https://github.com/owner/repo/blob/abc123/src/Example.php', + 'raw_url' => 'https://github.com/owner/repo/raw/abc123/src/Example.php', + 'contents_url' => 'https://api.github.com/repos/owner/repo/contents/src/Example.php?ref=abc123', + 'patch' => '@@ -1,5 +1,10 @@\n+new line', + 'sha' => 'abc123def456', + ]; + + $file = CommitFileData::fromArray($data); + + expect($file->filename)->toBe('src/Example.php'); + expect($file->status)->toBe('modified'); + expect($file->additions)->toBe(10); + expect($file->deletions)->toBe(5); + expect($file->changes)->toBe(15); + expect($file->blob_url)->toBe('https://github.com/owner/repo/blob/abc123/src/Example.php'); + expect($file->raw_url)->toBe('https://github.com/owner/repo/raw/abc123/src/Example.php'); + expect($file->contents_url)->toBe('https://api.github.com/repos/owner/repo/contents/src/Example.php?ref=abc123'); + expect($file->patch)->toBe('@@ -1,5 +1,10 @@\n+new line'); + expect($file->sha)->toBe('abc123def456'); +}); + +it('can convert CommitFileData to array', function () { + $file = new CommitFileData( + filename: 'test.php', + status: 'added', + additions: 20, + deletions: 0, + changes: 20, + blob_url: 'https://github.com/blob', + raw_url: 'https://github.com/raw', + contents_url: 'https://api.github.com/contents', + patch: '@@ -0,0 +1,20 @@', + sha: 'def789', + ); + + $array = $file->toArray(); + + expect($array['filename'])->toBe('test.php'); + expect($array['status'])->toBe('added'); + expect($array['additions'])->toBe(20); + expect($array['deletions'])->toBe(0); + expect($array['changes'])->toBe(20); + expect($array['patch'])->toBe('@@ -0,0 +1,20 @@'); + expect($array['sha'])->toBe('def789'); +}); + +it('handles optional fields as null', function () { + $data = [ + 'filename' => 'deleted.php', + 'status' => 'removed', + 'additions' => 0, + 'deletions' => 50, + 'changes' => 50, + 'blob_url' => 'https://github.com/blob', + 'raw_url' => 'https://github.com/raw', + 'contents_url' => 'https://api.github.com/contents', + ]; + + $file = CommitFileData::fromArray($data); + + expect($file->patch)->toBeNull(); + expect($file->sha)->toBeNull(); +}); diff --git a/tests/Unit/Data/Commits/CommitStatsDataTest.php b/tests/Unit/Data/Commits/CommitStatsDataTest.php new file mode 100644 index 0000000..1ac5e5b --- /dev/null +++ b/tests/Unit/Data/Commits/CommitStatsDataTest.php @@ -0,0 +1,47 @@ + 150, + 'additions' => 100, + 'deletions' => 50, + ]; + + $stats = CommitStatsData::fromArray($data); + + expect($stats->total)->toBe(150); + expect($stats->additions)->toBe(100); + expect($stats->deletions)->toBe(50); +}); + +it('can convert CommitStatsData to array', function () { + $stats = new CommitStatsData( + total: 200, + additions: 150, + deletions: 50, + ); + + $array = $stats->toArray(); + + expect($array)->toBe([ + 'total' => 200, + 'additions' => 150, + 'deletions' => 50, + ]); +}); + +it('handles zero values correctly', function () { + $data = [ + 'total' => 0, + 'additions' => 0, + 'deletions' => 0, + ]; + + $stats = CommitStatsData::fromArray($data); + + expect($stats->total)->toBe(0); + expect($stats->additions)->toBe(0); + expect($stats->deletions)->toBe(0); +}); diff --git a/tests/Unit/Data/FileDTOTest.php b/tests/Unit/Data/FileDTOTest.php new file mode 100644 index 0000000..b013852 --- /dev/null +++ b/tests/Unit/Data/FileDTOTest.php @@ -0,0 +1,129 @@ + 'abc123def456', + 'filename' => 'src/Example.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + 'raw_url' => 'https://github.com/owner/repo/raw/abc123/src/Example.php', + 'contents_url' => 'https://api.github.com/repos/owner/repo/contents/src/Example.php?ref=abc123', + 'blob_url' => 'https://github.com/owner/repo/blob/abc123/src/Example.php', + 'patch' => '@@ -1,5 +1,10 @@\n+new line', + 'size' => 1024, + ]; + + $file = FileDTO::fromArray($data); + + expect($file->sha)->toBe('abc123def456'); + expect($file->filename)->toBe('src/Example.php'); + expect($file->status)->toBe('modified'); + expect($file->additions)->toBe(10); + expect($file->deletions)->toBe(5); + expect($file->changes)->toBe(15); + expect($file->raw_url)->toBe('https://github.com/owner/repo/raw/abc123/src/Example.php'); + expect($file->contents_url)->toBe('https://api.github.com/repos/owner/repo/contents/src/Example.php?ref=abc123'); + expect($file->blob_url)->toBe('https://github.com/owner/repo/blob/abc123/src/Example.php'); + expect($file->patch)->toBe('@@ -1,5 +1,10 @@\n+new line'); + expect($file->size)->toBe(1024); +}); + +it('can convert FileDTO to array', function () { + $file = new FileDTO( + sha: 'xyz789', + filename: 'test.php', + status: 'added', + additions: 20, + deletions: 0, + changes: 20, + raw_url: 'https://github.com/raw', + contents_url: 'https://api.github.com/contents', + blob_url: 'https://github.com/blob', + patch: '@@ +new code', + size: 512, + ); + + $array = $file->toArray(); + + expect($array['sha'])->toBe('xyz789'); + expect($array['filename'])->toBe('test.php'); + expect($array['status'])->toBe('added'); + expect($array['additions'])->toBe(20); + expect($array['deletions'])->toBe(0); + expect($array['changes'])->toBe(20); + expect($array['patch'])->toBe('@@ +new code'); + expect($array['size'])->toBe(512); +}); + +it('handles optional fields with defaults', function () { + $data = [ + 'sha' => 'abc123', + 'filename' => 'file.php', + 'status' => 'modified', + ]; + + $file = FileDTO::fromArray($data); + + expect($file->additions)->toBe(0); + expect($file->deletions)->toBe(0); + expect($file->changes)->toBe(0); + expect($file->raw_url)->toBe(''); + expect($file->contents_url)->toBe(''); + expect($file->blob_url)->toBe(''); + expect($file->patch)->toBeNull(); + expect($file->size)->toBeNull(); +}); + +it('handles null patch', function () { + $data = [ + 'sha' => 'abc123', + 'filename' => 'binary.png', + 'status' => 'added', + ]; + + $file = FileDTO::fromArray($data); + + expect($file->patch)->toBeNull(); +}); + +it('handles null size', function () { + $data = [ + 'sha' => 'abc123', + 'filename' => 'file.php', + 'status' => 'modified', + ]; + + $file = FileDTO::fromArray($data); + + expect($file->size)->toBeNull(); +}); + +it('handles deleted file status', function () { + $data = [ + 'sha' => 'abc123', + 'filename' => 'deleted.php', + 'status' => 'removed', + 'deletions' => 50, + ]; + + $file = FileDTO::fromArray($data); + + expect($file->status)->toBe('removed'); + expect($file->deletions)->toBe(50); +}); + +it('handles renamed file status', function () { + $data = [ + 'sha' => 'abc123', + 'filename' => 'newname.php', + 'status' => 'renamed', + ]; + + $file = FileDTO::fromArray($data); + + expect($file->status)->toBe('renamed'); +}); diff --git a/tests/Unit/Data/GitUserDataTest.php b/tests/Unit/Data/GitUserDataTest.php index 9ae162d..30c9773 100644 --- a/tests/Unit/Data/GitUserDataTest.php +++ b/tests/Unit/Data/GitUserDataTest.php @@ -65,3 +65,198 @@ expect($array)->toHaveKey('avatar_url'); expect($array)->toHaveKey('url'); }); + +it('handles missing gravatar_id with default', function () { + $data = [ + 'login' => 'testuser', + 'id' => 123, + 'node_id' => 'MDQ6VXNlcjEyMw==', + 'avatar_url' => 'https://github.com/testuser.png', + 'url' => 'https://api.github.com/users/testuser', + 'html_url' => 'https://github.com/testuser', + 'followers_url' => 'https://api.github.com/users/testuser/followers', + 'following_url' => 'https://api.github.com/users/testuser/following{/other_user}', + 'gists_url' => 'https://api.github.com/users/testuser/gists{/gist_id}', + 'starred_url' => 'https://api.github.com/users/testuser/starred{/owner}{/repo}', + 'subscriptions_url' => 'https://api.github.com/users/testuser/subscriptions', + 'organizations_url' => 'https://api.github.com/users/testuser/orgs', + 'repos_url' => 'https://api.github.com/users/testuser/repos', + 'events_url' => 'https://api.github.com/users/testuser/events{/privacy}', + 'received_events_url' => 'https://api.github.com/users/testuser/received_events', + 'type' => 'User', + ]; + + $user = GitUserData::fromArray($data); + + expect($user->gravatar_id)->toBe(''); +}); + +it('handles missing user_view_type with default', function () { + $data = [ + 'login' => 'testuser', + 'id' => 123, + 'node_id' => 'MDQ6VXNlcjEyMw==', + 'avatar_url' => 'https://github.com/testuser.png', + 'gravatar_id' => '', + 'url' => 'https://api.github.com/users/testuser', + 'html_url' => 'https://github.com/testuser', + 'followers_url' => 'https://api.github.com/users/testuser/followers', + 'following_url' => 'https://api.github.com/users/testuser/following{/other_user}', + 'gists_url' => 'https://api.github.com/users/testuser/gists{/gist_id}', + 'starred_url' => 'https://api.github.com/users/testuser/starred{/owner}{/repo}', + 'subscriptions_url' => 'https://api.github.com/users/testuser/subscriptions', + 'organizations_url' => 'https://api.github.com/users/testuser/orgs', + 'repos_url' => 'https://api.github.com/users/testuser/repos', + 'events_url' => 'https://api.github.com/users/testuser/events{/privacy}', + 'received_events_url' => 'https://api.github.com/users/testuser/received_events', + 'type' => 'User', + ]; + + $user = GitUserData::fromArray($data); + + expect($user->user_view_type)->toBe(''); +}); + +it('handles missing site_admin with default', function () { + $data = [ + 'login' => 'testuser', + 'id' => 123, + 'node_id' => 'MDQ6VXNlcjEyMw==', + 'avatar_url' => 'https://github.com/testuser.png', + 'gravatar_id' => '', + 'url' => 'https://api.github.com/users/testuser', + 'html_url' => 'https://github.com/testuser', + 'followers_url' => 'https://api.github.com/users/testuser/followers', + 'following_url' => 'https://api.github.com/users/testuser/following{/other_user}', + 'gists_url' => 'https://api.github.com/users/testuser/gists{/gist_id}', + 'starred_url' => 'https://api.github.com/users/testuser/starred{/owner}{/repo}', + 'subscriptions_url' => 'https://api.github.com/users/testuser/subscriptions', + 'organizations_url' => 'https://api.github.com/users/testuser/orgs', + 'repos_url' => 'https://api.github.com/users/testuser/repos', + 'events_url' => 'https://api.github.com/users/testuser/events{/privacy}', + 'received_events_url' => 'https://api.github.com/users/testuser/received_events', + 'type' => 'User', + ]; + + $user = GitUserData::fromArray($data); + + expect($user->site_admin)->toBeFalse(); +}); + +it('handles Organization type', function () { + $data = [ + 'login' => 'github', + 'id' => 9919, + 'node_id' => 'MDEyOk9yZ2FuaXphdGlvbjk5MTk=', + 'avatar_url' => 'https://github.com/github.png', + 'gravatar_id' => '', + 'url' => 'https://api.github.com/orgs/github', + 'html_url' => 'https://github.com/github', + 'followers_url' => 'https://api.github.com/users/github/followers', + 'following_url' => 'https://api.github.com/users/github/following{/other_user}', + 'gists_url' => 'https://api.github.com/users/github/gists{/gist_id}', + 'starred_url' => 'https://api.github.com/users/github/starred{/owner}{/repo}', + 'subscriptions_url' => 'https://api.github.com/users/github/subscriptions', + 'organizations_url' => 'https://api.github.com/users/github/orgs', + 'repos_url' => 'https://api.github.com/users/github/repos', + 'events_url' => 'https://api.github.com/users/github/events{/privacy}', + 'received_events_url' => 'https://api.github.com/users/github/received_events', + 'type' => 'Organization', + 'site_admin' => false, + ]; + + $user = GitUserData::fromArray($data); + + expect($user->type)->toBe('Organization'); +}); + +it('handles Bot type', function () { + $data = [ + 'login' => 'dependabot[bot]', + 'id' => 49699333, + 'node_id' => 'MDM6Qm90NDk2OTkzMzM=', + 'avatar_url' => 'https://github.com/dependabot.png', + 'gravatar_id' => '', + 'url' => 'https://api.github.com/users/dependabot%5Bbot%5D', + 'html_url' => 'https://github.com/apps/dependabot', + 'followers_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/followers', + 'following_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/following{/other_user}', + 'gists_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/gists{/gist_id}', + 'starred_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/starred{/owner}{/repo}', + 'subscriptions_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/subscriptions', + 'organizations_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/orgs', + 'repos_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/repos', + 'events_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/events{/privacy}', + 'received_events_url' => 'https://api.github.com/users/dependabot%5Bbot%5D/received_events', + 'type' => 'Bot', + 'site_admin' => false, + ]; + + $user = GitUserData::fromArray($data); + + expect($user->type)->toBe('Bot'); + expect($user->login)->toBe('dependabot[bot]'); +}); + +it('handles site admin user', function () { + $data = [ + 'login' => 'admin', + 'id' => 1, + 'node_id' => 'MDQ6VXNlcjE=', + 'avatar_url' => 'https://github.com/admin.png', + 'gravatar_id' => '', + 'url' => 'https://api.github.com/users/admin', + 'html_url' => 'https://github.com/admin', + 'followers_url' => 'https://api.github.com/users/admin/followers', + 'following_url' => 'https://api.github.com/users/admin/following{/other_user}', + 'gists_url' => 'https://api.github.com/users/admin/gists{/gist_id}', + 'starred_url' => 'https://api.github.com/users/admin/starred{/owner}{/repo}', + 'subscriptions_url' => 'https://api.github.com/users/admin/subscriptions', + 'organizations_url' => 'https://api.github.com/users/admin/orgs', + 'repos_url' => 'https://api.github.com/users/admin/repos', + 'events_url' => 'https://api.github.com/users/admin/events{/privacy}', + 'received_events_url' => 'https://api.github.com/users/admin/received_events', + 'type' => 'User', + 'site_admin' => true, + ]; + + $user = GitUserData::fromArray($data); + + expect($user->site_admin)->toBeTrue(); +}); + +it('preserves all URL fields', function () { + $user = new GitUserData( + login: 'test', + id: 1, + node_id: 'node123', + avatar_url: 'https://avatar.url', + gravatar_id: 'gravatar123', + url: 'https://api.url', + html_url: 'https://html.url', + followers_url: 'https://followers.url', + following_url: 'https://following.url', + gists_url: 'https://gists.url', + starred_url: 'https://starred.url', + subscriptions_url: 'https://subscriptions.url', + organizations_url: 'https://organizations.url', + repos_url: 'https://repos.url', + events_url: 'https://events.url', + received_events_url: 'https://received_events.url', + type: 'User', + user_view_type: 'public', + site_admin: false, + ); + + $array = $user->toArray(); + + expect($array['followers_url'])->toBe('https://followers.url'); + expect($array['following_url'])->toBe('https://following.url'); + expect($array['gists_url'])->toBe('https://gists.url'); + expect($array['starred_url'])->toBe('https://starred.url'); + expect($array['subscriptions_url'])->toBe('https://subscriptions.url'); + expect($array['organizations_url'])->toBe('https://organizations.url'); + expect($array['repos_url'])->toBe('https://repos.url'); + expect($array['events_url'])->toBe('https://events.url'); + expect($array['received_events_url'])->toBe('https://received_events.url'); +}); diff --git a/tests/Unit/Data/Issues/IssueCommentDTOTest.php b/tests/Unit/Data/Issues/IssueCommentDTOTest.php new file mode 100644 index 0000000..7ffbddf --- /dev/null +++ b/tests/Unit/Data/Issues/IssueCommentDTOTest.php @@ -0,0 +1,59 @@ +sampleData = [ + 'id' => 98765, + 'body' => 'This is a test comment on the issue.', + 'user' => $this->createMockUserData('commenter', 5), + 'html_url' => 'https://github.com/owner/repo/issues/42#issuecomment-98765', + 'created_at' => '2024-01-16T10:00:00Z', + 'updated_at' => '2024-01-16T10:30:00Z', + ]; +}); + +it('can create IssueCommentDTO from API response', function () { + $comment = IssueCommentDTO::fromApiResponse($this->sampleData); + + expect($comment->id)->toBe(98765); + expect($comment->body)->toBe('This is a test comment on the issue.'); + expect($comment->user)->toBeInstanceOf(GitUserData::class); + expect($comment->user->login)->toBe('commenter'); + expect($comment->html_url)->toBe('https://github.com/owner/repo/issues/42#issuecomment-98765'); + expect($comment->created_at)->toBe('2024-01-16T10:00:00Z'); + expect($comment->updated_at)->toBe('2024-01-16T10:30:00Z'); +}); + +it('can convert IssueCommentDTO to array', function () { + $comment = IssueCommentDTO::fromApiResponse($this->sampleData); + $array = $comment->toArray(); + + expect($array['id'])->toBe(98765); + expect($array['body'])->toBe('This is a test comment on the issue.'); + expect($array['user'])->toBeArray(); + expect($array['user']['login'])->toBe('commenter'); + expect($array['html_url'])->toBe('https://github.com/owner/repo/issues/42#issuecomment-98765'); + expect($array['created_at'])->toBe('2024-01-16T10:00:00Z'); + expect($array['updated_at'])->toBe('2024-01-16T10:30:00Z'); +}); + +it('handles markdown in comment body', function () { + $markdownData = $this->sampleData; + $markdownData['body'] = "## Header\n\n```php\necho 'hello';\n```\n\n- List item"; + + $comment = IssueCommentDTO::fromApiResponse($markdownData); + + expect($comment->body)->toContain('## Header'); + expect($comment->body)->toContain('```php'); +}); + +it('handles emoji in comment body', function () { + $emojiData = $this->sampleData; + $emojiData['body'] = 'Great work! :+1: :rocket:'; + + $comment = IssueCommentDTO::fromApiResponse($emojiData); + + expect($comment->body)->toBe('Great work! :+1: :rocket:'); +}); diff --git a/tests/Unit/Data/Issues/IssueDTOTest.php b/tests/Unit/Data/Issues/IssueDTOTest.php new file mode 100644 index 0000000..c5ad9fe --- /dev/null +++ b/tests/Unit/Data/Issues/IssueDTOTest.php @@ -0,0 +1,134 @@ +sampleData = [ + 'id' => 12345, + 'number' => 42, + 'title' => 'Bug: Login not working', + 'body' => 'When I try to login, it fails.', + 'state' => 'open', + 'assignee' => $this->createMockUserData('assignee', 2), + 'assignees' => [ + $this->createMockUserData('assignee1', 2), + $this->createMockUserData('assignee2', 3), + ], + 'labels' => [ + [ + 'id' => 1, + 'name' => 'bug', + 'color' => 'ff0000', + 'description' => 'Something is broken', + 'default' => false, + ], + [ + 'id' => 2, + 'name' => 'priority-high', + 'color' => 'ff6600', + 'description' => 'High priority', + 'default' => false, + ], + ], + 'comments' => 5, + 'html_url' => 'https://github.com/owner/repo/issues/42', + 'user' => $this->createMockUserData('reporter', 1), + 'created_at' => '2024-01-15T10:30:00Z', + 'updated_at' => '2024-01-16T14:00:00Z', + 'closed_at' => null, + ]; +}); + +it('can create IssueDTO from API response', function () { + $issue = IssueDTO::fromApiResponse($this->sampleData); + + expect($issue->id)->toBe(12345); + expect($issue->number)->toBe(42); + expect($issue->title)->toBe('Bug: Login not working'); + expect($issue->body)->toBe('When I try to login, it fails.'); + expect($issue->state)->toBe('open'); + expect($issue->assignee)->toBeInstanceOf(GitUserData::class); + expect($issue->assignee->login)->toBe('assignee'); + expect($issue->assignees)->toHaveCount(2); + expect($issue->assignees[0])->toBeInstanceOf(GitUserData::class); + expect($issue->labels)->toHaveCount(2); + expect($issue->labels[0])->toBeInstanceOf(LabelDTO::class); + expect($issue->labels[0]->name)->toBe('bug'); + expect($issue->comments)->toBe(5); + expect($issue->html_url)->toBe('https://github.com/owner/repo/issues/42'); + expect($issue->user)->toBeInstanceOf(GitUserData::class); + expect($issue->user->login)->toBe('reporter'); + expect($issue->created_at)->toBe('2024-01-15T10:30:00Z'); + expect($issue->updated_at)->toBe('2024-01-16T14:00:00Z'); + expect($issue->closed_at)->toBeNull(); +}); + +it('can convert IssueDTO to array', function () { + $issue = IssueDTO::fromApiResponse($this->sampleData); + $array = $issue->toArray(); + + expect($array['id'])->toBe(12345); + expect($array['number'])->toBe(42); + expect($array['title'])->toBe('Bug: Login not working'); + expect($array['body'])->toBe('When I try to login, it fails.'); + expect($array['state'])->toBe('open'); + expect($array['assignee'])->toBeArray(); + expect($array['assignee']['login'])->toBe('assignee'); + expect($array['assignees'])->toHaveCount(2); + expect($array['labels'])->toHaveCount(2); + expect($array['labels'][0]['name'])->toBe('bug'); + expect($array['comments'])->toBe(5); + expect($array['user']['login'])->toBe('reporter'); +}); + +it('throws exception for pull request data', function () { + $prData = array_merge($this->sampleData, [ + 'pull_request' => [ + 'url' => 'https://api.github.com/repos/owner/repo/pulls/42', + ], + ]); + + IssueDTO::fromApiResponse($prData); +})->throws(InvalidArgumentException::class, 'This is a pull request, not an issue'); + +it('handles null assignee', function () { + $dataWithNullAssignee = $this->sampleData; + $dataWithNullAssignee['assignee'] = null; + + $issue = IssueDTO::fromApiResponse($dataWithNullAssignee); + + expect($issue->assignee)->toBeNull(); +}); + +it('handles empty body', function () { + $dataWithEmptyBody = $this->sampleData; + unset($dataWithEmptyBody['body']); + + $issue = IssueDTO::fromApiResponse($dataWithEmptyBody); + + expect($issue->body)->toBe(''); +}); + +it('handles closed issue with closed_at date', function () { + $closedIssue = $this->sampleData; + $closedIssue['state'] = 'closed'; + $closedIssue['closed_at'] = '2024-01-17T09:00:00Z'; + + $issue = IssueDTO::fromApiResponse($closedIssue); + + expect($issue->state)->toBe('closed'); + expect($issue->closed_at)->toBe('2024-01-17T09:00:00Z'); +}); + +it('handles empty labels and assignees', function () { + $dataWithEmpty = $this->sampleData; + $dataWithEmpty['labels'] = []; + $dataWithEmpty['assignees'] = []; + + $issue = IssueDTO::fromApiResponse($dataWithEmpty); + + expect($issue->labels)->toBe([]); + expect($issue->assignees)->toBe([]); +}); diff --git a/tests/Unit/Data/Issues/LabelDTOTest.php b/tests/Unit/Data/Issues/LabelDTOTest.php new file mode 100644 index 0000000..899acf5 --- /dev/null +++ b/tests/Unit/Data/Issues/LabelDTOTest.php @@ -0,0 +1,82 @@ + 12345, + 'name' => 'bug', + 'color' => 'ff0000', + 'description' => 'Something is not working', + 'default' => false, + ]; + + $label = LabelDTO::fromApiResponse($data); + + expect($label->id)->toBe(12345); + expect($label->name)->toBe('bug'); + expect($label->color)->toBe('ff0000'); + expect($label->description)->toBe('Something is not working'); + expect($label->default)->toBeFalse(); +}); + +it('can convert LabelDTO to array', function () { + $label = new LabelDTO( + id: 54321, + name: 'enhancement', + color: '00ff00', + description: 'New feature request', + default: false, + ); + + $array = $label->toArray(); + + expect($array)->toBe([ + 'id' => 54321, + 'name' => 'enhancement', + 'color' => '00ff00', + 'description' => 'New feature request', + 'default' => false, + ]); +}); + +it('handles null description', function () { + $data = [ + 'id' => 111, + 'name' => 'wontfix', + 'color' => '999999', + 'default' => true, + ]; + + $label = LabelDTO::fromApiResponse($data); + + expect($label->description)->toBeNull(); + expect($label->default)->toBeTrue(); +}); + +it('handles default labels', function () { + $data = [ + 'id' => 222, + 'name' => 'documentation', + 'color' => '0075ca', + 'description' => 'Improvements or additions to documentation', + 'default' => true, + ]; + + $label = LabelDTO::fromApiResponse($data); + + expect($label->default)->toBeTrue(); +}); + +it('handles missing default field', function () { + $data = [ + 'id' => 333, + 'name' => 'custom', + 'color' => 'abcdef', + 'description' => 'Custom label', + ]; + + $label = LabelDTO::fromApiResponse($data); + + expect($label->default)->toBeFalse(); +}); diff --git a/tests/Unit/Data/Pulls/CommentMetadataTest.php b/tests/Unit/Data/Pulls/CommentMetadataTest.php new file mode 100644 index 0000000..14dbba3 --- /dev/null +++ b/tests/Unit/Data/Pulls/CommentMetadataTest.php @@ -0,0 +1,171 @@ + ['Important']], + ); + + expect($metadata->severity)->toBe('high'); + expect($metadata->file_path)->toBe('src/Example.php'); + expect($metadata->line_number)->toBe(42); + expect($metadata->code_snippet)->toBe('echo $unsafe;'); + expect($metadata->claim_type)->toBe('security'); + expect($metadata->reviewer_type)->toBe('human'); + expect($metadata->raw_patterns)->toBe(['bold' => ['Important']]); +}); + +it('can convert CommentMetadata to array', function () { + $metadata = new CommentMetadata( + severity: 'medium', + file_path: 'test.php', + line_number: 10, + ); + + $array = $metadata->toArray(); + + expect($array['severity'])->toBe('medium'); + expect($array['file_path'])->toBe('test.php'); + expect($array['line_number'])->toBe(10); + expect($array['code_snippet'])->toBeNull(); + expect($array['claim_type'])->toBeNull(); + expect($array['reviewer_type'])->toBeNull(); + expect($array['raw_patterns'])->toBe([]); +}); + +it('extracts high severity from explicit marker', function () { + $metadata = CommentMetadata::extract('[SEVERITY: HIGH] Critical bug found!', 'file.php', 10, 'reviewer'); + + expect($metadata->severity)->toBe('high'); +}); + +it('extracts medium severity from warning keyword', function () { + $metadata = CommentMetadata::extract('Warning: This could cause issues.', 'file.php', 10, 'reviewer'); + + expect($metadata->severity)->toBe('medium'); +}); + +it('extracts low severity from suggestion keyword', function () { + $metadata = CommentMetadata::extract('Suggestion: Use a different approach.', 'file.php', 10, 'reviewer'); + + expect($metadata->severity)->toBe('low'); +}); + +it('extracts severity from emoji', function () { + $highMetadata = CommentMetadata::extract('This is bad', 'file.php', 10, 'reviewer'); + $mediumMetadata = CommentMetadata::extract('Be careful here ⚠️', 'file.php', 10, 'reviewer'); + $lowMetadata = CommentMetadata::extract('This is fine ✅', 'file.php', 10, 'reviewer'); + + expect($mediumMetadata->severity)->toBe('medium'); + expect($lowMetadata->severity)->toBe('low'); +}); + +it('extracts line number from position', function () { + $metadata = CommentMetadata::extract('Comment body', 'file.php', 25, 'reviewer'); + + expect($metadata->line_number)->toBe(25); +}); + +it('extracts line number from comment text', function () { + $metadata = CommentMetadata::extract('Check line 42 for the issue', 'file.php', null, 'reviewer'); + + expect($metadata->line_number)->toBe(42); +}); + +it('extracts code snippet from code block', function () { + $body = "Here's the fix:\n```php\necho 'fixed';\n```"; + $metadata = CommentMetadata::extract($body, 'file.php', 10, 'reviewer'); + + expect($metadata->code_snippet)->toBe("echo 'fixed';"); +}); + +it('extracts code snippet from inline code', function () { + $body = 'Change `$variable` to `$newVariable`'; + $metadata = CommentMetadata::extract($body, 'file.php', 10, 'reviewer'); + + expect($metadata->code_snippet)->toBe('$variable'); +}); + +it('detects sql injection claim type', function () { + $metadata = CommentMetadata::extract('This is vulnerable to SQL injection!', 'file.php', 10, 'reviewer'); + + expect($metadata->claim_type)->toBe('sql_injection'); +}); + +it('detects xss claim type', function () { + $metadata = CommentMetadata::extract('This could lead to XSS attacks.', 'file.php', 10, 'reviewer'); + + expect($metadata->claim_type)->toBe('xss'); +}); + +it('detects performance claim type', function () { + $metadata = CommentMetadata::extract('This has performance implications.', 'file.php', 10, 'reviewer'); + + expect($metadata->claim_type)->toBe('performance'); +}); + +it('detects unused code claim type', function () { + $metadata = CommentMetadata::extract('This unused variable should be removed.', 'file.php', 10, 'reviewer'); + + expect($metadata->claim_type)->toBe('unused_code'); +}); + +it('determines human reviewer type', function () { + $metadata = CommentMetadata::extract('Nice work!', 'file.php', 10, 'johndoe'); + + expect($metadata->reviewer_type)->toBe('human'); +}); + +it('determines coderabbit reviewer type', function () { + $metadata = CommentMetadata::extract('Nice work!', 'file.php', 10, 'coderabbitai[bot]'); + + expect($metadata->reviewer_type)->toBe('coderabbit'); +}); + +it('determines dependabot reviewer type', function () { + $metadata = CommentMetadata::extract('Bumping version', 'file.php', 10, 'dependabot[bot]'); + + expect($metadata->reviewer_type)->toBe('dependabot'); +}); + +it('determines github actions reviewer type', function () { + $metadata = CommentMetadata::extract('CI result', 'file.php', 10, 'github-actions[bot]'); + + expect($metadata->reviewer_type)->toBe('github_actions'); +}); + +it('determines bot reviewer type for generic bot', function () { + $metadata = CommentMetadata::extract('Automated comment', 'file.php', 10, 'mybot[bot]'); + + expect($metadata->reviewer_type)->toBe('bot'); +}); + +it('extracts raw patterns from markdown', function () { + $body = '**Important**: Check this `code` and [link](http://example.com)'; + $metadata = CommentMetadata::extract($body, 'file.php', 10, 'reviewer'); + + expect($metadata->raw_patterns)->toHaveKey('bold'); + expect($metadata->raw_patterns['bold'])->toContain('Important'); + expect($metadata->raw_patterns)->toHaveKey('links'); +}); + +it('handles null author', function () { + $metadata = CommentMetadata::extract('Comment', 'file.php', 10, null); + + expect($metadata->reviewer_type)->toBeNull(); +}); + +it('handles body without patterns', function () { + $metadata = CommentMetadata::extract('Simple comment without patterns', 'file.php', 10, 'reviewer'); + + expect($metadata->severity)->toBeNull(); + expect($metadata->code_snippet)->toBeNull(); + expect($metadata->claim_type)->toBeNull(); +}); diff --git a/tests/Unit/Data/Pulls/MergeResponseDTOTest.php b/tests/Unit/Data/Pulls/MergeResponseDTOTest.php new file mode 100644 index 0000000..3dcb943 --- /dev/null +++ b/tests/Unit/Data/Pulls/MergeResponseDTOTest.php @@ -0,0 +1,57 @@ + true, + 'sha' => 'abc123def456789', + 'message' => 'Pull Request successfully merged', + ]; + + $response = MergeResponseDTO::fromApiResponse($data); + + expect($response->merged)->toBeTrue(); + expect($response->sha)->toBe('abc123def456789'); + expect($response->message)->toBe('Pull Request successfully merged'); +}); + +it('can convert MergeResponseDTO to array', function () { + $response = new MergeResponseDTO( + merged: true, + sha: 'xyz789', + message: 'Merged successfully', + ); + + $array = $response->toArray(); + + expect($array)->toBe([ + 'merged' => true, + 'sha' => 'xyz789', + 'message' => 'Merged successfully', + ]); +}); + +it('handles failed merge response', function () { + $data = [ + 'merged' => false, + 'sha' => '', + 'message' => 'Pull Request could not be merged', + ]; + + $response = MergeResponseDTO::fromApiResponse($data); + + expect($response->merged)->toBeFalse(); + expect($response->sha)->toBe(''); + expect($response->message)->toBe('Pull Request could not be merged'); +}); + +it('handles missing fields with defaults', function () { + $data = []; + + $response = MergeResponseDTO::fromApiResponse($data); + + expect($response->merged)->toBeFalse(); + expect($response->sha)->toBe(''); + expect($response->message)->toBe(''); +}); diff --git a/tests/Unit/Data/Pulls/ParamsTest.php b/tests/Unit/Data/Pulls/ParamsTest.php new file mode 100644 index 0000000..991435b --- /dev/null +++ b/tests/Unit/Data/Pulls/ParamsTest.php @@ -0,0 +1,116 @@ + 'open', + 'head' => 'user:feature-branch', + 'base' => 'main', + 'sort' => 'created', + 'direction' => 'desc', + 'per_page' => '30', + 'page' => '1', + ]; + + $params = Params::fromArray($data); + + expect($params->state)->toBe(State::OPEN); + expect($params->head)->toBe('user:feature-branch'); + expect($params->base)->toBe('main'); + expect($params->sort)->toBe(Sort::CREATED); + expect($params->direction)->toBe(Direction::DESC); + expect($params->per_page)->toBe('30'); + expect($params->page)->toBe('1'); +}); + +it('can convert Params to array', function () { + $params = new Params( + state: State::CLOSED, + head: 'user:branch', + base: 'develop', + sort: Sort::UPDATED, + direction: Direction::ASC, + per_page: '50', + page: '2', + ); + + $array = $params->toArray(); + + expect($array['state'])->toBe('closed'); + expect($array['head'])->toBe('user:branch'); + expect($array['base'])->toBe('develop'); + expect($array['sort'])->toBe('updated'); + expect($array['direction'])->toBe('asc'); + expect($array['per_page'])->toBe('50'); + expect($array['page'])->toBe('2'); +}); + +it('handles null fields', function () { + $data = []; + + $params = Params::fromArray($data); + + expect($params->state)->toBeNull(); + expect($params->head)->toBeNull(); + expect($params->base)->toBeNull(); + expect($params->sort)->toBeNull(); + expect($params->direction)->toBeNull(); + expect($params->per_page)->toBeNull(); + expect($params->page)->toBeNull(); +}); + +it('converts null fields to null in array', function () { + $params = new Params( + state: null, + head: null, + base: null, + sort: null, + direction: null, + per_page: null, + page: null, + ); + + $array = $params->toArray(); + + expect($array['state'])->toBeNull(); + expect($array['head'])->toBeNull(); + expect($array['base'])->toBeNull(); + expect($array['sort'])->toBeNull(); + expect($array['direction'])->toBeNull(); + expect($array['per_page'])->toBeNull(); + expect($array['page'])->toBeNull(); +}); + +it('handles all state values', function () { + $openParams = Params::fromArray(['state' => 'open']); + $closedParams = Params::fromArray(['state' => 'closed']); + $allParams = Params::fromArray(['state' => 'all']); + + expect($openParams->state)->toBe(State::OPEN); + expect($closedParams->state)->toBe(State::CLOSED); + expect($allParams->state)->toBe(State::ALL); +}); + +it('handles all sort values', function () { + $createdParams = Params::fromArray(['sort' => 'created']); + $updatedParams = Params::fromArray(['sort' => 'updated']); + $popularityParams = Params::fromArray(['sort' => 'popularity']); + $longRunningParams = Params::fromArray(['sort' => 'long-running']); + + expect($createdParams->sort)->toBe(Sort::CREATED); + expect($updatedParams->sort)->toBe(Sort::UPDATED); + expect($popularityParams->sort)->toBe(Sort::POPULARITY); + expect($longRunningParams->sort)->toBe(Sort::LONG_RUNNING); +}); + +it('handles all direction values', function () { + $ascParams = Params::fromArray(['direction' => 'asc']); + $descParams = Params::fromArray(['direction' => 'desc']); + + expect($ascParams->direction)->toBe(Direction::ASC); + expect($descParams->direction)->toBe(Direction::DESC); +}); diff --git a/tests/Unit/Data/Pulls/PullRequestCommentDTOTest.php b/tests/Unit/Data/Pulls/PullRequestCommentDTOTest.php new file mode 100644 index 0000000..bdd65fc --- /dev/null +++ b/tests/Unit/Data/Pulls/PullRequestCommentDTOTest.php @@ -0,0 +1,93 @@ +sampleData = [ + 'id' => 123456, + 'node_id' => 'PRR_comment123', + 'path' => 'src/Example.php', + 'position' => 10, + 'original_position' => 10, + 'commit_id' => 'abc123def456', + 'original_commit_id' => 'abc123def456', + 'user' => $this->createMockUserData('reviewer', 2), + 'body' => 'This looks good, but consider refactoring this method.', + 'html_url' => 'https://github.com/owner/repo/pull/42#discussion_r123456', + 'pull_request_url' => 'https://api.github.com/repos/owner/repo/pulls/42', + 'created_at' => '2024-01-16T10:00:00Z', + 'updated_at' => '2024-01-16T10:00:00Z', + ]; +}); + +it('can create PullRequestCommentDTO from array', function () { + $comment = PullRequestCommentDTO::fromArray($this->sampleData); + + expect($comment->id)->toBe(123456); + expect($comment->node_id)->toBe('PRR_comment123'); + expect($comment->path)->toBe('src/Example.php'); + expect($comment->position)->toBe(10); + expect($comment->original_position)->toBe(10); + expect($comment->commit_id)->toBe('abc123def456'); + expect($comment->original_commit_id)->toBe('abc123def456'); + expect($comment->user)->toBeInstanceOf(GitUserData::class); + expect($comment->user->login)->toBe('reviewer'); + expect($comment->body)->toBe('This looks good, but consider refactoring this method.'); + expect($comment->html_url)->toBe('https://github.com/owner/repo/pull/42#discussion_r123456'); + expect($comment->pull_request_url)->toBe('https://api.github.com/repos/owner/repo/pulls/42'); + expect($comment->created_at)->toBe('2024-01-16T10:00:00Z'); + expect($comment->updated_at)->toBe('2024-01-16T10:00:00Z'); + expect($comment->metadata)->toBeInstanceOf(CommentMetadata::class); +}); + +it('can create PullRequestCommentDTO from API response', function () { + $comment = PullRequestCommentDTO::fromApiResponse($this->sampleData); + + expect($comment->id)->toBe(123456); + expect($comment->path)->toBe('src/Example.php'); +}); + +it('can convert PullRequestCommentDTO to array', function () { + $comment = PullRequestCommentDTO::fromArray($this->sampleData); + $array = $comment->toArray(); + + expect($array['id'])->toBe(123456); + expect($array['node_id'])->toBe('PRR_comment123'); + expect($array['path'])->toBe('src/Example.php'); + expect($array['position'])->toBe(10); + expect($array['user']['login'])->toBe('reviewer'); + expect($array['body'])->toBe('This looks good, but consider refactoring this method.'); + expect($array['metadata'])->toBeArray(); +}); + +it('handles missing position with default', function () { + $dataWithoutPosition = $this->sampleData; + unset($dataWithoutPosition['position']); + unset($dataWithoutPosition['original_position']); + + $comment = PullRequestCommentDTO::fromArray($dataWithoutPosition); + + expect($comment->position)->toBe(-1); + expect($comment->original_position)->toBe(-1); +}); + +it('extracts metadata from comment with severity', function () { + $dataWithSeverity = $this->sampleData; + $dataWithSeverity['body'] = '[SEVERITY: HIGH] This is a critical security issue!'; + + $comment = PullRequestCommentDTO::fromArray($dataWithSeverity); + + expect($comment->metadata)->toBeInstanceOf(CommentMetadata::class); + expect($comment->metadata->severity)->toBe('high'); +}); + +it('extracts metadata from bot reviewer', function () { + $botData = $this->sampleData; + $botData['user']['login'] = 'coderabbitai[bot]'; + + $comment = PullRequestCommentDTO::fromArray($botData); + + expect($comment->metadata->reviewer_type)->toBe('coderabbit'); +}); diff --git a/tests/Unit/Data/Pulls/PullRequestDTOFactoryTest.php b/tests/Unit/Data/Pulls/PullRequestDTOFactoryTest.php new file mode 100644 index 0000000..7953cec --- /dev/null +++ b/tests/Unit/Data/Pulls/PullRequestDTOFactoryTest.php @@ -0,0 +1,118 @@ +listResponseData = [ + 'id' => 123456, + 'number' => 42, + 'state' => 'open', + 'title' => 'Add new feature', + 'body' => 'This PR adds a new feature.', + 'html_url' => 'https://github.com/owner/repo/pull/42', + 'diff_url' => 'https://github.com/owner/repo/pull/42.diff', + 'patch_url' => 'https://github.com/owner/repo/pull/42.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature-branch'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'user' => $this->createMockUserData('developer', 1), + 'merged_by' => null, + 'created_at' => '2024-01-15T10:00:00Z', + 'updated_at' => '2024-01-16T14:30:00Z', + 'closed_at' => null, + ]; + + $this->detailResponseData = array_merge($this->listResponseData, [ + 'comments' => 5, + 'review_comments' => 10, + 'commits' => 3, + 'additions' => 150, + 'deletions' => 50, + 'changed_files' => 8, + 'mergeable' => true, + 'mergeable_state' => 'clean', + 'rebaseable' => true, + ]); +}); + +it('creates summary DTO from list response', function () { + $dto = PullRequestDTOFactory::fromResponse($this->listResponseData); + + expect($dto)->toBeInstanceOf(PullRequestSummaryDTO::class); + expect($dto)->not->toBeInstanceOf(PullRequestDetailDTO::class); +}); + +it('creates detail DTO from detail response', function () { + $dto = PullRequestDTOFactory::fromResponse($this->detailResponseData); + + expect($dto)->toBeInstanceOf(PullRequestDetailDTO::class); +}); + +it('creates DTOs from response array', function () { + $dataArray = [ + $this->listResponseData, + $this->detailResponseData, + ]; + + $dtos = PullRequestDTOFactory::fromResponseArray($dataArray); + + expect($dtos)->toHaveCount(2); + expect($dtos[0])->toBeInstanceOf(PullRequestSummaryDTO::class); + expect($dtos[1])->toBeInstanceOf(PullRequestDetailDTO::class); +}); + +it('forces creation of summary DTO', function () { + $dto = PullRequestDTOFactory::createSummary($this->detailResponseData); + + expect($dto)->toBeInstanceOf(PullRequestSummaryDTO::class); + expect($dto)->not->toBeInstanceOf(PullRequestDetailDTO::class); +}); + +it('forces creation of detail DTO', function () { + $dto = PullRequestDTOFactory::createDetail($this->detailResponseData); + + expect($dto)->toBeInstanceOf(PullRequestDetailDTO::class); +}); + +it('analyzes list response correctly', function () { + $analysis = PullRequestDTOFactory::analyzeResponse($this->listResponseData); + + expect($analysis['would_create'])->toBe('PullRequestSummaryDTO'); + expect($analysis['has_detailed_fields'])->toBeFalse(); + expect($analysis['detail_fields_present'])->toBeEmpty(); +}); + +it('analyzes detail response correctly', function () { + $analysis = PullRequestDTOFactory::analyzeResponse($this->detailResponseData); + + expect($analysis['would_create'])->toBe('PullRequestDetailDTO'); + expect($analysis['has_detailed_fields'])->toBeTrue(); + expect($analysis['detail_fields_present'])->toContain('comments'); + expect($analysis['detail_fields_present'])->toContain('additions'); + expect($analysis['detail_fields_present'])->toContain('deletions'); +}); + +it('detects detail response with partial fields', function () { + $partialData = $this->listResponseData; + $partialData['comments'] = 5; + $partialData['additions'] = 100; + + $dto = PullRequestDTOFactory::fromResponse($partialData); + + expect($dto)->toBeInstanceOf(PullRequestDetailDTO::class); +}); + +it('treats response as summary when only one detail field present', function () { + $oneFieldData = $this->listResponseData; + $oneFieldData['comments'] = 5; + + $dto = PullRequestDTOFactory::fromResponse($oneFieldData); + + expect($dto)->toBeInstanceOf(PullRequestSummaryDTO::class); + expect($dto)->not->toBeInstanceOf(PullRequestDetailDTO::class); +}); diff --git a/tests/Unit/Data/Pulls/PullRequestDTOTest.php b/tests/Unit/Data/Pulls/PullRequestDTOTest.php new file mode 100644 index 0000000..568c973 --- /dev/null +++ b/tests/Unit/Data/Pulls/PullRequestDTOTest.php @@ -0,0 +1,141 @@ +sampleData = [ + 'id' => 123456, + 'number' => 42, + 'state' => 'open', + 'title' => 'Add new feature', + 'body' => 'This PR adds a new feature for user authentication.', + 'html_url' => 'https://github.com/owner/repo/pull/42', + 'diff_url' => 'https://github.com/owner/repo/pull/42.diff', + 'patch_url' => 'https://github.com/owner/repo/pull/42.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature-auth'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'comments' => 5, + 'review_comments' => 10, + 'commits' => 3, + 'additions' => 150, + 'deletions' => 50, + 'changed_files' => 8, + 'user' => $this->createMockUserData('developer', 1), + 'merged_by' => null, + 'created_at' => '2024-01-15T10:00:00Z', + 'updated_at' => '2024-01-16T14:30:00Z', + 'closed_at' => null, + ]; +}); + +it('can create PullRequestDTO from API response', function () { + $pr = PullRequestDTO::fromApiResponse($this->sampleData); + + expect($pr->id)->toBe(123456); + expect($pr->number)->toBe(42); + expect($pr->state)->toBe('open'); + expect($pr->title)->toBe('Add new feature'); + expect($pr->body)->toBe('This PR adds a new feature for user authentication.'); + expect($pr->html_url)->toBe('https://github.com/owner/repo/pull/42'); + expect($pr->diff_url)->toBe('https://github.com/owner/repo/pull/42.diff'); + expect($pr->patch_url)->toBe('https://github.com/owner/repo/pull/42.patch'); + expect($pr->base_ref)->toBe('main'); + expect($pr->head_ref)->toBe('feature-auth'); + expect($pr->draft)->toBeFalse(); + expect($pr->merged)->toBeFalse(); + expect($pr->merged_at)->toBeNull(); + expect($pr->merge_commit_sha)->toBeNull(); + expect($pr->comments)->toBe(5); + expect($pr->review_comments)->toBe(10); + expect($pr->commits)->toBe(3); + expect($pr->additions)->toBe(150); + expect($pr->deletions)->toBe(50); + expect($pr->changed_files)->toBe(8); + expect($pr->user)->toBeInstanceOf(GitUserData::class); + expect($pr->user->login)->toBe('developer'); + expect($pr->merged_by)->toBeNull(); + expect($pr->created_at)->toBe('2024-01-15T10:00:00Z'); + expect($pr->updated_at)->toBe('2024-01-16T14:30:00Z'); + expect($pr->closed_at)->toBeNull(); +}); + +it('can convert PullRequestDTO to array', function () { + $pr = PullRequestDTO::fromApiResponse($this->sampleData); + $array = $pr->toArray(); + + expect($array['id'])->toBe(123456); + expect($array['number'])->toBe(42); + expect($array['state'])->toBe('open'); + expect($array['title'])->toBe('Add new feature'); + expect($array['base_ref'])->toBe('main'); + expect($array['head_ref'])->toBe('feature-auth'); + expect($array['user']['login'])->toBe('developer'); + expect($array['comments'])->toBe(5); + expect($array['additions'])->toBe(150); +}); + +it('handles merged PR', function () { + $mergedData = $this->sampleData; + $mergedData['state'] = 'closed'; + $mergedData['merged'] = true; + $mergedData['merged_at'] = '2024-01-17T09:00:00Z'; + $mergedData['merge_commit_sha'] = 'abc123def456'; + $mergedData['merged_by'] = $this->createMockUserData('maintainer', 2); + $mergedData['closed_at'] = '2024-01-17T09:00:00Z'; + + $pr = PullRequestDTO::fromApiResponse($mergedData); + + expect($pr->state)->toBe('closed'); + expect($pr->merged)->toBeTrue(); + expect($pr->merged_at)->toBe('2024-01-17T09:00:00Z'); + expect($pr->merge_commit_sha)->toBe('abc123def456'); + expect($pr->merged_by)->toBeInstanceOf(GitUserData::class); + expect($pr->merged_by->login)->toBe('maintainer'); + expect($pr->closed_at)->toBe('2024-01-17T09:00:00Z'); +}); + +it('handles draft PR', function () { + $draftData = $this->sampleData; + $draftData['draft'] = true; + + $pr = PullRequestDTO::fromApiResponse($draftData); + + expect($pr->draft)->toBeTrue(); +}); + +it('handles empty body', function () { + $dataWithEmptyBody = $this->sampleData; + unset($dataWithEmptyBody['body']); + + $pr = PullRequestDTO::fromApiResponse($dataWithEmptyBody); + + expect($pr->body)->toBe(''); +}); + +it('handles missing optional fields with defaults', function () { + $minimalData = $this->sampleData; + unset($minimalData['comments']); + unset($minimalData['review_comments']); + unset($minimalData['commits']); + unset($minimalData['additions']); + unset($minimalData['deletions']); + unset($minimalData['changed_files']); + unset($minimalData['draft']); + unset($minimalData['merged']); + + $pr = PullRequestDTO::fromApiResponse($minimalData); + + expect($pr->comments)->toBe(0); + expect($pr->review_comments)->toBe(0); + expect($pr->commits)->toBe(0); + expect($pr->additions)->toBe(0); + expect($pr->deletions)->toBe(0); + expect($pr->changed_files)->toBe(0); + expect($pr->draft)->toBeFalse(); + expect($pr->merged)->toBeFalse(); +}); diff --git a/tests/Unit/Data/Pulls/PullRequestDetailDTOTest.php b/tests/Unit/Data/Pulls/PullRequestDetailDTOTest.php new file mode 100644 index 0000000..4acd0d2 --- /dev/null +++ b/tests/Unit/Data/Pulls/PullRequestDetailDTOTest.php @@ -0,0 +1,250 @@ +sampleData = [ + 'id' => 123456, + 'number' => 42, + 'state' => 'open', + 'title' => 'Add new feature', + 'body' => 'This PR adds a new feature.', + 'html_url' => 'https://github.com/owner/repo/pull/42', + 'diff_url' => 'https://github.com/owner/repo/pull/42.diff', + 'patch_url' => 'https://github.com/owner/repo/pull/42.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature-branch'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'user' => $this->createMockUserData('developer', 1), + 'merged_by' => null, + 'created_at' => '2024-01-15T10:00:00Z', + 'updated_at' => '2024-01-16T14:30:00Z', + 'closed_at' => null, + 'comments' => 5, + 'review_comments' => 10, + 'commits' => 3, + 'additions' => 150, + 'deletions' => 50, + 'changed_files' => 8, + 'mergeable' => true, + 'mergeable_state' => 'clean', + 'rebaseable' => true, + ]; +}); + +it('can create PullRequestDetailDTO from detail response', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail)->toBeInstanceOf(PullRequestDetailDTO::class); + expect($detail)->toBeInstanceOf(PullRequestSummaryDTO::class); + expect($detail->id)->toBe(123456); + expect($detail->number)->toBe(42); + expect($detail->comments)->toBe(5); + expect($detail->review_comments)->toBe(10); + expect($detail->commits)->toBe(3); + expect($detail->additions)->toBe(150); + expect($detail->deletions)->toBe(50); + expect($detail->changed_files)->toBe(8); + expect($detail->mergeable)->toBeTrue(); + expect($detail->mergeable_state)->toBe('clean'); + expect($detail->rebaseable)->toBeTrue(); +}); + +it('can convert PullRequestDetailDTO to array', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + $array = $detail->toArray(); + + expect($array['id'])->toBe(123456); + expect($array['comments'])->toBe(5); + expect($array['review_comments'])->toBe(10); + expect($array['commits'])->toBe(3); + expect($array['additions'])->toBe(150); + expect($array['deletions'])->toBe(50); + expect($array['changed_files'])->toBe(8); + expect($array['mergeable'])->toBeTrue(); + expect($array['mergeable_state'])->toBe('clean'); + expect($array['rebaseable'])->toBeTrue(); +}); + +it('has detailed data returns true for detail', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->hasDetailedData())->toBeTrue(); +}); + +it('calculates total lines changed', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->getTotalLinesChanged())->toBe(200); +}); + +it('calculates addition ratio', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->getAdditionRatio())->toBe(0.75); +}); + +it('calculates addition ratio with zero changes', function () { + $zeroData = $this->sampleData; + $zeroData['additions'] = 0; + $zeroData['deletions'] = 0; + + $detail = PullRequestDetailDTO::fromDetailResponse($zeroData); + + expect($detail->getAdditionRatio())->toBe(0.0); +}); + +it('detects if has comments', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->hasComments())->toBeTrue(); +}); + +it('detects if has no comments', function () { + $noCommentsData = $this->sampleData; + $noCommentsData['comments'] = 0; + $noCommentsData['review_comments'] = 0; + + $detail = PullRequestDetailDTO::fromDetailResponse($noCommentsData); + + expect($detail->hasComments())->toBeFalse(); +}); + +it('calculates total comments', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->getTotalComments())->toBe(15); +}); + +it('detects ready to merge status', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->isReadyToMerge())->toBeTrue(); +}); + +it('detects not ready to merge when mergeable is false', function () { + $notMergeableData = $this->sampleData; + $notMergeableData['mergeable'] = false; + + $detail = PullRequestDetailDTO::fromDetailResponse($notMergeableData); + + expect($detail->isReadyToMerge())->toBeFalse(); +}); + +it('detects merge conflicts', function () { + $conflictData = $this->sampleData; + $conflictData['mergeable'] = false; + $conflictData['mergeable_state'] = 'dirty'; + + $detail = PullRequestDetailDTO::fromDetailResponse($conflictData); + + expect($detail->hasMergeConflicts())->toBeTrue(); +}); + +it('detects can rebase', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->canRebase())->toBeTrue(); +}); + +it('detects cannot rebase', function () { + $noRebaseData = $this->sampleData; + $noRebaseData['rebaseable'] = false; + + $detail = PullRequestDetailDTO::fromDetailResponse($noRebaseData); + + expect($detail->canRebase())->toBeFalse(); +}); + +it('gets merge status description for clean', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + + expect($detail->getMergeStatusDescription())->toBe('Ready to merge'); +}); + +it('gets merge status description for dirty', function () { + $dirtyData = $this->sampleData; + $dirtyData['mergeable_state'] = 'dirty'; + + $detail = PullRequestDetailDTO::fromDetailResponse($dirtyData); + + expect($detail->getMergeStatusDescription())->toBe('Has merge conflicts'); +}); + +it('gets merge status description for unstable', function () { + $unstableData = $this->sampleData; + $unstableData['mergeable_state'] = 'unstable'; + + $detail = PullRequestDetailDTO::fromDetailResponse($unstableData); + + expect($detail->getMergeStatusDescription())->toBe('Mergeable with failing checks'); +}); + +it('gets merge status description for blocked', function () { + $blockedData = $this->sampleData; + $blockedData['mergeable_state'] = 'blocked'; + + $detail = PullRequestDetailDTO::fromDetailResponse($blockedData); + + expect($detail->getMergeStatusDescription())->toBe('Blocked by branch protection'); +}); + +it('gets merge status description for behind', function () { + $behindData = $this->sampleData; + $behindData['mergeable_state'] = 'behind'; + + $detail = PullRequestDetailDTO::fromDetailResponse($behindData); + + expect($detail->getMergeStatusDescription())->toBe('Behind base branch'); +}); + +it('gets merge status description for draft', function () { + $draftData = $this->sampleData; + $draftData['mergeable_state'] = 'draft'; + + $detail = PullRequestDetailDTO::fromDetailResponse($draftData); + + expect($detail->getMergeStatusDescription())->toBe('Draft pull request'); +}); + +it('gets merge status description for unknown', function () { + $unknownData = $this->sampleData; + $unknownData['mergeable'] = null; + + $detail = PullRequestDetailDTO::fromDetailResponse($unknownData); + + expect($detail->getMergeStatusDescription())->toBe('Merge status unknown (checking...)'); +}); + +it('generates summary', function () { + $detail = PullRequestDetailDTO::fromDetailResponse($this->sampleData); + $summary = $detail->getSummary(); + + expect($summary['pr'])->toBe('#42: Add new feature'); + expect($summary['stats']['comments'])->toBe(5); + expect($summary['stats']['review_comments'])->toBe(10); + expect($summary['stats']['commits'])->toBe(3); + expect($summary['stats']['changes'])->toBe('+150/-50'); + expect($summary['stats']['files'])->toBe(8); + expect($summary['merge_status']['mergeable'])->toBeTrue(); + expect($summary['merge_status']['description'])->toBe('Ready to merge'); + expect($summary['state'])->toBe('open'); + expect($summary['author'])->toBe('developer'); +}); + +it('handles missing mergeable fields', function () { + $missingData = $this->sampleData; + unset($missingData['mergeable']); + unset($missingData['mergeable_state']); + unset($missingData['rebaseable']); + + $detail = PullRequestDetailDTO::fromDetailResponse($missingData); + + expect($detail->mergeable)->toBeNull(); + expect($detail->mergeable_state)->toBeNull(); + expect($detail->rebaseable)->toBeNull(); +}); diff --git a/tests/Unit/Data/Pulls/PullRequestFileDTOTest.php b/tests/Unit/Data/Pulls/PullRequestFileDTOTest.php new file mode 100644 index 0000000..2320199 --- /dev/null +++ b/tests/Unit/Data/Pulls/PullRequestFileDTOTest.php @@ -0,0 +1,245 @@ +sampleData = [ + 'sha' => 'abc123def456', + 'filename' => 'src/Controllers/AuthController.php', + 'status' => 'modified', + 'additions' => 50, + 'deletions' => 20, + 'changes' => 70, + 'blob_url' => 'https://github.com/owner/repo/blob/abc123/src/Controllers/AuthController.php', + 'raw_url' => 'https://github.com/owner/repo/raw/abc123/src/Controllers/AuthController.php', + 'contents_url' => 'https://api.github.com/repos/owner/repo/contents/src/Controllers/AuthController.php?ref=abc123', + 'patch' => '@@ -10,5 +10,25 @@\n+new code here', + ]; +}); + +it('can create PullRequestFileDTO from API response', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->sha)->toBe('abc123def456'); + expect($file->filename)->toBe('src/Controllers/AuthController.php'); + expect($file->status)->toBe('modified'); + expect($file->additions)->toBe(50); + expect($file->deletions)->toBe(20); + expect($file->changes)->toBe(70); + expect($file->blob_url)->toBe('https://github.com/owner/repo/blob/abc123/src/Controllers/AuthController.php'); + expect($file->raw_url)->toBe('https://github.com/owner/repo/raw/abc123/src/Controllers/AuthController.php'); + expect($file->contents_url)->toBe('https://api.github.com/repos/owner/repo/contents/src/Controllers/AuthController.php?ref=abc123'); + expect($file->patch)->toBe('@@ -10,5 +10,25 @@\n+new code here'); + expect($file->previous_filename)->toBeNull(); +}); + +it('can convert PullRequestFileDTO to array', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + $array = $file->toArray(); + + expect($array['sha'])->toBe('abc123def456'); + expect($array['filename'])->toBe('src/Controllers/AuthController.php'); + expect($array['status'])->toBe('modified'); + expect($array['additions'])->toBe(50); + expect($array['patch'])->toBe('@@ -10,5 +10,25 @@\n+new code here'); +}); + +it('detects added file status', function () { + $addedData = $this->sampleData; + $addedData['status'] = 'added'; + + $file = PullRequestFileDTO::fromApiResponse($addedData); + + expect($file->isAdded())->toBeTrue(); + expect($file->isModified())->toBeFalse(); + expect($file->isDeleted())->toBeFalse(); + expect($file->isRenamed())->toBeFalse(); +}); + +it('detects deleted file status', function () { + $deletedData = $this->sampleData; + $deletedData['status'] = 'removed'; + + $file = PullRequestFileDTO::fromApiResponse($deletedData); + + expect($file->isDeleted())->toBeTrue(); + expect($file->isAdded())->toBeFalse(); +}); + +it('detects modified file status', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->isModified())->toBeTrue(); +}); + +it('detects renamed file status', function () { + $renamedData = $this->sampleData; + $renamedData['status'] = 'renamed'; + $renamedData['previous_filename'] = 'src/Controllers/OldAuthController.php'; + + $file = PullRequestFileDTO::fromApiResponse($renamedData); + + expect($file->isRenamed())->toBeTrue(); + expect($file->previous_filename)->toBe('src/Controllers/OldAuthController.php'); +}); + +it('gets file extension', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->getExtension())->toBe('php'); +}); + +it('gets directory path', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->getDirectory())->toBe('src/Controllers'); +}); + +it('gets basename', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->getBasename())->toBe('AuthController'); +}); + +it('calculates addition ratio', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->getAdditionRatio())->toBeGreaterThan(0.7); +}); + +it('calculates deletion ratio', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->getDeletionRatio())->toBeLessThan(0.3); +}); + +it('handles zero changes for ratios', function () { + $zeroChanges = $this->sampleData; + $zeroChanges['additions'] = 0; + $zeroChanges['deletions'] = 0; + $zeroChanges['changes'] = 0; + + $file = PullRequestFileDTO::fromApiResponse($zeroChanges); + + expect($file->getAdditionRatio())->toBe(0.0); + expect($file->getDeletionRatio())->toBe(0.0); +}); + +it('detects large change', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->isLargeChange(50))->toBeTrue(); + expect($file->isLargeChange(100))->toBeFalse(); +}); + +it('detects only additions', function () { + $onlyAdditions = $this->sampleData; + $onlyAdditions['additions'] = 50; + $onlyAdditions['deletions'] = 0; + + $file = PullRequestFileDTO::fromApiResponse($onlyAdditions); + + expect($file->hasOnlyAdditions())->toBeTrue(); + expect($file->hasOnlyDeletions())->toBeFalse(); +}); + +it('detects only deletions', function () { + $onlyDeletions = $this->sampleData; + $onlyDeletions['additions'] = 0; + $onlyDeletions['deletions'] = 50; + + $file = PullRequestFileDTO::fromApiResponse($onlyDeletions); + + expect($file->hasOnlyDeletions())->toBeTrue(); + expect($file->hasOnlyAdditions())->toBeFalse(); +}); + +it('detects file type', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + + expect($file->getFileType())->toBe('php'); +}); + +it('detects file type for JavaScript', function () { + $jsData = $this->sampleData; + $jsData['filename'] = 'src/app.js'; + + $file = PullRequestFileDTO::fromApiResponse($jsData); + + expect($file->getFileType())->toBe('javascript'); +}); + +it('detects file type for TypeScript', function () { + $tsData = $this->sampleData; + $tsData['filename'] = 'src/app.ts'; + + $file = PullRequestFileDTO::fromApiResponse($tsData); + + expect($file->getFileType())->toBe('typescript'); +}); + +it('detects file type for Dockerfile', function () { + $dockerData = $this->sampleData; + $dockerData['filename'] = 'Dockerfile'; + + $file = PullRequestFileDTO::fromApiResponse($dockerData); + + expect($file->getFileType())->toBe('docker'); +}); + +it('detects test file', function () { + $testData = $this->sampleData; + $testData['filename'] = 'tests/Unit/AuthTest.php'; + + $file = PullRequestFileDTO::fromApiResponse($testData); + + expect($file->isTestFile())->toBeTrue(); +}); + +it('detects config file', function () { + $configData = $this->sampleData; + $configData['filename'] = 'config/app.php'; + + $file = PullRequestFileDTO::fromApiResponse($configData); + + expect($file->isConfigFile())->toBeTrue(); +}); + +it('detects documentation file', function () { + $docData = $this->sampleData; + $docData['filename'] = 'README.md'; + + $file = PullRequestFileDTO::fromApiResponse($docData); + + expect($file->isDocumentationFile())->toBeTrue(); +}); + +it('generates summary', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + $summary = $file->getSummary(); + + expect($summary['file'])->toBe('src/Controllers/AuthController.php'); + expect($summary['status'])->toBe('modified'); + expect($summary['changes'])->toBe('+50/-20'); + expect($summary['type'])->toBe('php'); +}); + +it('generates analysis tags', function () { + $file = PullRequestFileDTO::fromApiResponse($this->sampleData); + $tags = $file->getAnalysisTags(); + + expect($tags)->toContain('php'); + expect($tags)->toContain('modified'); +}); + +it('generates analysis tags for test file', function () { + $testData = $this->sampleData; + $testData['filename'] = 'tests/Unit/AuthTest.php'; + $testData['changes'] = 150; + + $file = PullRequestFileDTO::fromApiResponse($testData); + $tags = $file->getAnalysisTags(); + + expect($tags)->toContain('test'); + expect($tags)->toContain('large-change'); +}); diff --git a/tests/Unit/Data/Pulls/PullRequestReviewDTOTest.php b/tests/Unit/Data/Pulls/PullRequestReviewDTOTest.php new file mode 100644 index 0000000..8d5218a --- /dev/null +++ b/tests/Unit/Data/Pulls/PullRequestReviewDTOTest.php @@ -0,0 +1,90 @@ +sampleData = [ + 'id' => 987654, + 'node_id' => 'PRR_review123', + 'user' => $this->createMockUserData('reviewer', 5), + 'body' => 'Looks good overall, just a few minor suggestions.', + 'state' => 'APPROVED', + 'html_url' => 'https://github.com/owner/repo/pull/42#pullrequestreview-987654', + 'pull_request_url' => 'https://api.github.com/repos/owner/repo/pulls/42', + 'commit_id' => 'abc123def456', + 'submitted_at' => '2024-01-16T14:00:00Z', + ]; +}); + +it('can create PullRequestReviewDTO from array', function () { + $review = PullRequestReviewDTO::fromArray($this->sampleData); + + expect($review->id)->toBe(987654); + expect($review->node_id)->toBe('PRR_review123'); + expect($review->user)->toBeInstanceOf(GitUserData::class); + expect($review->user->login)->toBe('reviewer'); + expect($review->body)->toBe('Looks good overall, just a few minor suggestions.'); + expect($review->state)->toBe('APPROVED'); + expect($review->html_url)->toBe('https://github.com/owner/repo/pull/42#pullrequestreview-987654'); + expect($review->pull_request_url)->toBe('https://api.github.com/repos/owner/repo/pulls/42'); + expect($review->commit_id)->toBe('abc123def456'); + expect($review->submitted_at)->toBe('2024-01-16T14:00:00Z'); +}); + +it('can create PullRequestReviewDTO from API response', function () { + $review = PullRequestReviewDTO::fromApiResponse($this->sampleData); + + expect($review->id)->toBe(987654); + expect($review->state)->toBe('APPROVED'); +}); + +it('can convert PullRequestReviewDTO to array', function () { + $review = PullRequestReviewDTO::fromArray($this->sampleData); + $array = $review->toArray(); + + expect($array['id'])->toBe(987654); + expect($array['node_id'])->toBe('PRR_review123'); + expect($array['user']['login'])->toBe('reviewer'); + expect($array['body'])->toBe('Looks good overall, just a few minor suggestions.'); + expect($array['state'])->toBe('APPROVED'); + expect($array['commit_id'])->toBe('abc123def456'); + expect($array['submitted_at'])->toBe('2024-01-16T14:00:00Z'); +}); + +it('handles CHANGES_REQUESTED state', function () { + $changesData = $this->sampleData; + $changesData['state'] = 'CHANGES_REQUESTED'; + $changesData['body'] = 'Please fix the issues mentioned in the comments.'; + + $review = PullRequestReviewDTO::fromArray($changesData); + + expect($review->state)->toBe('CHANGES_REQUESTED'); +}); + +it('handles COMMENTED state', function () { + $commentedData = $this->sampleData; + $commentedData['state'] = 'COMMENTED'; + + $review = PullRequestReviewDTO::fromArray($commentedData); + + expect($review->state)->toBe('COMMENTED'); +}); + +it('handles PENDING state', function () { + $pendingData = $this->sampleData; + $pendingData['state'] = 'PENDING'; + + $review = PullRequestReviewDTO::fromArray($pendingData); + + expect($review->state)->toBe('PENDING'); +}); + +it('handles empty body', function () { + $emptyBodyData = $this->sampleData; + unset($emptyBodyData['body']); + + $review = PullRequestReviewDTO::fromArray($emptyBodyData); + + expect($review->body)->toBe(''); +}); diff --git a/tests/Unit/Data/Pulls/PullRequestSummaryDTOTest.php b/tests/Unit/Data/Pulls/PullRequestSummaryDTOTest.php new file mode 100644 index 0000000..24bd57e --- /dev/null +++ b/tests/Unit/Data/Pulls/PullRequestSummaryDTOTest.php @@ -0,0 +1,110 @@ +sampleData = [ + 'id' => 123456, + 'number' => 42, + 'state' => 'open', + 'title' => 'Add new feature', + 'body' => 'This PR adds a new feature.', + 'html_url' => 'https://github.com/owner/repo/pull/42', + 'diff_url' => 'https://github.com/owner/repo/pull/42.diff', + 'patch_url' => 'https://github.com/owner/repo/pull/42.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature-branch'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'user' => $this->createMockUserData('developer', 1), + 'merged_by' => null, + 'created_at' => '2024-01-15T10:00:00Z', + 'updated_at' => '2024-01-16T14:30:00Z', + 'closed_at' => null, + ]; +}); + +it('can create PullRequestSummaryDTO from list response', function () { + $summary = PullRequestSummaryDTO::fromListResponse($this->sampleData); + + expect($summary->id)->toBe(123456); + expect($summary->number)->toBe(42); + expect($summary->state)->toBe('open'); + expect($summary->title)->toBe('Add new feature'); + expect($summary->body)->toBe('This PR adds a new feature.'); + expect($summary->html_url)->toBe('https://github.com/owner/repo/pull/42'); + expect($summary->diff_url)->toBe('https://github.com/owner/repo/pull/42.diff'); + expect($summary->patch_url)->toBe('https://github.com/owner/repo/pull/42.patch'); + expect($summary->base_ref)->toBe('main'); + expect($summary->head_ref)->toBe('feature-branch'); + expect($summary->draft)->toBeFalse(); + expect($summary->merged)->toBeFalse(); + expect($summary->merged_at)->toBeNull(); + expect($summary->merge_commit_sha)->toBeNull(); + expect($summary->user)->toBeInstanceOf(GitUserData::class); + expect($summary->user->login)->toBe('developer'); + expect($summary->merged_by)->toBeNull(); + expect($summary->created_at)->toBe('2024-01-15T10:00:00Z'); + expect($summary->updated_at)->toBe('2024-01-16T14:30:00Z'); + expect($summary->closed_at)->toBeNull(); +}); + +it('can convert PullRequestSummaryDTO to array', function () { + $summary = PullRequestSummaryDTO::fromListResponse($this->sampleData); + $array = $summary->toArray(); + + expect($array['id'])->toBe(123456); + expect($array['number'])->toBe(42); + expect($array['state'])->toBe('open'); + expect($array['title'])->toBe('Add new feature'); + expect($array['base_ref'])->toBe('main'); + expect($array['head_ref'])->toBe('feature-branch'); + expect($array['user']['login'])->toBe('developer'); + expect($array['merged_by'])->toBeNull(); +}); + +it('has detailed data returns false for summary', function () { + $summary = PullRequestSummaryDTO::fromListResponse($this->sampleData); + + expect($summary->hasDetailedData())->toBeFalse(); +}); + +it('handles merged PR summary', function () { + $mergedData = $this->sampleData; + $mergedData['state'] = 'closed'; + $mergedData['merged'] = true; + $mergedData['merged_at'] = '2024-01-17T09:00:00Z'; + $mergedData['merge_commit_sha'] = 'abc123def456'; + $mergedData['merged_by'] = $this->createMockUserData('maintainer', 2); + $mergedData['closed_at'] = '2024-01-17T09:00:00Z'; + + $summary = PullRequestSummaryDTO::fromListResponse($mergedData); + + expect($summary->state)->toBe('closed'); + expect($summary->merged)->toBeTrue(); + expect($summary->merged_at)->toBe('2024-01-17T09:00:00Z'); + expect($summary->merge_commit_sha)->toBe('abc123def456'); + expect($summary->merged_by)->toBeInstanceOf(GitUserData::class); + expect($summary->merged_by->login)->toBe('maintainer'); +}); + +it('handles draft PR summary', function () { + $draftData = $this->sampleData; + $draftData['draft'] = true; + + $summary = PullRequestSummaryDTO::fromListResponse($draftData); + + expect($summary->draft)->toBeTrue(); +}); + +it('handles empty body', function () { + $noBodyData = $this->sampleData; + unset($noBodyData['body']); + + $summary = PullRequestSummaryDTO::fromListResponse($noBodyData); + + expect($summary->body)->toBe(''); +}); diff --git a/tests/Unit/Data/RateLimitDTOTest.php b/tests/Unit/Data/RateLimitDTOTest.php new file mode 100644 index 0000000..edcdae8 --- /dev/null +++ b/tests/Unit/Data/RateLimitDTOTest.php @@ -0,0 +1,210 @@ + 5000, + 'remaining' => 4500, + 'reset' => $resetTime, + 'used' => 500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data, 'core'); + + expect($rateLimit->limit)->toBe(5000); + expect($rateLimit->remaining)->toBe(4500); + expect($rateLimit->reset->getTimestamp())->toBe($resetTime); + expect($rateLimit->used)->toBe(500); + expect($rateLimit->resource)->toBe('core'); +}); + +it('can convert RateLimitDTO to array', function () { + $resetTime = time() + 3600; + $data = [ + 'limit' => 5000, + 'remaining' => 4500, + 'reset' => $resetTime, + 'used' => 500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data, 'core'); + $array = $rateLimit->toArray(); + + expect($array['limit'])->toBe(5000); + expect($array['remaining'])->toBe(4500); + expect($array['used'])->toBe(500); + expect($array['resource'])->toBe('core'); + expect($array['reset_timestamp'])->toBe($resetTime); + expect($array)->toHaveKey('reset'); + expect($array)->toHaveKey('usage_percentage'); + expect($array)->toHaveKey('seconds_until_reset'); + expect($array)->toHaveKey('is_exceeded'); + expect($array)->toHaveKey('is_approaching_limit'); +}); + +it('detects exceeded rate limit', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 0, + 'reset' => time() + 3600, + 'used' => 5000, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->isExceeded())->toBeTrue(); +}); + +it('detects not exceeded rate limit', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 1000, + 'reset' => time() + 3600, + 'used' => 4000, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->isExceeded())->toBeFalse(); +}); + +it('calculates seconds until reset', function () { + $resetTime = time() + 3600; + $data = [ + 'limit' => 5000, + 'remaining' => 4500, + 'reset' => $resetTime, + 'used' => 500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->getSecondsUntilReset())->toBeGreaterThanOrEqual(3595); + expect($rateLimit->getSecondsUntilReset())->toBeLessThanOrEqual(3600); +}); + +it('returns zero for past reset time', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 4500, + 'reset' => time() - 100, + 'used' => 500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->getSecondsUntilReset())->toBe(0); +}); + +it('calculates minutes until reset', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 4500, + 'reset' => time() + 3600, + 'used' => 500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->getMinutesUntilReset())->toBeGreaterThanOrEqual(59.0); + expect($rateLimit->getMinutesUntilReset())->toBeLessThanOrEqual(60.0); +}); + +it('calculates usage percentage', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 2500, + 'reset' => time() + 3600, + 'used' => 2500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->getUsagePercentage())->toBe(50.0); +}); + +it('detects approaching limit at default threshold', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 500, + 'reset' => time() + 3600, + 'used' => 4500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->isApproachingLimit())->toBeTrue(); +}); + +it('detects not approaching limit', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 3000, + 'reset' => time() + 3600, + 'used' => 2000, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->isApproachingLimit())->toBeFalse(); +}); + +it('allows custom threshold for approaching limit', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 2500, + 'reset' => time() + 3600, + 'used' => 2500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->isApproachingLimit(50.0))->toBeTrue(); + expect($rateLimit->isApproachingLimit(60.0))->toBeFalse(); +}); + +it('handles different resource types', function () { + $resources = ['core', 'search', 'graphql', 'integration_manifest', 'code_scanning_upload']; + + foreach ($resources as $resource) { + $data = [ + 'limit' => 5000, + 'remaining' => 4500, + 'reset' => time() + 3600, + 'used' => 500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data, $resource); + expect($rateLimit->resource)->toBe($resource); + } +}); + +it('uses core as default resource', function () { + $data = [ + 'limit' => 5000, + 'remaining' => 4500, + 'reset' => time() + 3600, + 'used' => 500, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data); + + expect($rateLimit->resource)->toBe('core'); +}); + +it('handles search rate limit with lower limits', function () { + $data = [ + 'limit' => 30, + 'remaining' => 10, + 'reset' => time() + 60, + 'used' => 20, + ]; + + $rateLimit = RateLimitDTO::fromApiResponse($data, 'search'); + + expect($rateLimit->limit)->toBe(30); + expect($rateLimit->resource)->toBe('search'); + expect($rateLimit->getUsagePercentage())->toBeGreaterThan(66.0); +}); diff --git a/tests/Unit/Data/Releases/ReleaseDataTest.php b/tests/Unit/Data/Releases/ReleaseDataTest.php new file mode 100644 index 0000000..1cb2edc --- /dev/null +++ b/tests/Unit/Data/Releases/ReleaseDataTest.php @@ -0,0 +1,163 @@ +sampleData = [ + 'url' => 'https://api.github.com/repos/owner/repo/releases/1', + 'assets_url' => 'https://api.github.com/repos/owner/repo/releases/1/assets', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/1/assets{?name,label}', + 'html_url' => 'https://github.com/owner/repo/releases/tag/v1.0.0', + 'id' => 12345, + 'author' => $this->createMockUserData('releaser', 1), + 'node_id' => 'RE_release123', + 'tag_name' => 'v1.0.0', + 'target_commitish' => 'main', + 'name' => 'Version 1.0.0', + 'draft' => false, + 'prerelease' => false, + 'created_at' => '2024-01-15T10:00:00Z', + 'published_at' => '2024-01-15T12:00:00Z', + 'assets' => [ + [ + 'name' => 'package.zip', + 'content_type' => 'application/zip', + 'size' => 1024, + ], + ], + 'tarball_url' => 'https://api.github.com/repos/owner/repo/tarball/v1.0.0', + 'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v1.0.0', + 'body' => '## Changelog\n\n- Added new feature\n- Fixed bugs', + ]; +}); + +it('can create ReleaseData from array', function () { + $release = ReleaseData::fromArray($this->sampleData); + + expect($release->url)->toBe('https://api.github.com/repos/owner/repo/releases/1'); + expect($release->assets_url)->toBe('https://api.github.com/repos/owner/repo/releases/1/assets'); + expect($release->upload_url)->toBe('https://uploads.github.com/repos/owner/repo/releases/1/assets{?name,label}'); + expect($release->html_url)->toBe('https://github.com/owner/repo/releases/tag/v1.0.0'); + expect($release->id)->toBe(12345); + expect($release->author)->toBeInstanceOf(GitUserData::class); + expect($release->author->login)->toBe('releaser'); + expect($release->node_id)->toBe('RE_release123'); + expect($release->tag_name)->toBe('v1.0.0'); + expect($release->target_commitish)->toBe('main'); + expect($release->name)->toBe('Version 1.0.0'); + expect($release->draft)->toBeFalse(); + expect($release->prerelease)->toBeFalse(); + expect($release->created_at)->toBeInstanceOf(Carbon::class); + expect($release->published_at)->toBeInstanceOf(Carbon::class); + expect($release->assets)->toBeArray(); + expect($release->assets)->toHaveCount(1); + expect($release->tarball_url)->toBe('https://api.github.com/repos/owner/repo/tarball/v1.0.0'); + expect($release->zipball_url)->toBe('https://api.github.com/repos/owner/repo/zipball/v1.0.0'); + expect($release->body)->toBe('## Changelog\n\n- Added new feature\n- Fixed bugs'); +}); + +it('can convert ReleaseData to array', function () { + $release = ReleaseData::fromArray($this->sampleData); + $array = $release->toArray(); + + expect($array['url'])->toBe('https://api.github.com/repos/owner/repo/releases/1'); + expect($array['id'])->toBe(12345); + expect($array['author'])->toBeArray(); + expect($array['author']['login'])->toBe('releaser'); + expect($array['tag_name'])->toBe('v1.0.0'); + expect($array['name'])->toBe('Version 1.0.0'); + expect($array['draft'])->toBeFalse(); + expect($array['prerelease'])->toBeFalse(); + expect($array['created_at'])->toBeString(); + expect($array['published_at'])->toBeString(); +}); + +it('handles draft release', function () { + $draftData = $this->sampleData; + $draftData['draft'] = true; + + $release = ReleaseData::fromArray($draftData); + + expect($release->draft)->toBeTrue(); +}); + +it('handles prerelease', function () { + $prereleaseData = $this->sampleData; + $prereleaseData['prerelease'] = true; + + $release = ReleaseData::fromArray($prereleaseData); + + expect($release->prerelease)->toBeTrue(); +}); + +it('handles null body', function () { + $noBodyData = $this->sampleData; + unset($noBodyData['body']); + + $release = ReleaseData::fromArray($noBodyData); + + expect($release->body)->toBeNull(); +}); + +it('handles empty assets', function () { + $noAssetsData = $this->sampleData; + unset($noAssetsData['assets']); + + $release = ReleaseData::fromArray($noAssetsData); + + expect($release->assets)->toBe([]); +}); + +it('handles discussion_url', function () { + $discussionData = $this->sampleData; + $discussionData['discussion_url'] = 'https://github.com/owner/repo/discussions/1'; + + $release = ReleaseData::fromArray($discussionData); + + expect($release->discussion_url)->toBe('https://github.com/owner/repo/discussions/1'); +}); + +it('handles make_latest flag', function () { + $latestData = $this->sampleData; + $latestData['make_latest'] = true; + + $release = ReleaseData::fromArray($latestData); + + expect($release->make_latest)->toBeTrue(); +}); + +it('handles reactions', function () { + $reactionsData = $this->sampleData; + $reactionsData['reactions'] = [ + 'url' => 'https://api.github.com/repos/owner/repo/releases/1/reactions', + '+1' => 10, + 'hooray' => 5, + ]; + + $release = ReleaseData::fromArray($reactionsData); + + expect($release->reactions)->toBeArray(); + expect($release->reactions['+1'])->toBe(10); +}); + +it('handles multiple assets', function () { + $multipleAssetsData = $this->sampleData; + $multipleAssetsData['assets'] = [ + ['name' => 'package-linux.tar.gz', 'size' => 2048], + ['name' => 'package-macos.tar.gz', 'size' => 2048], + ['name' => 'package-windows.zip', 'size' => 3072], + ]; + + $release = ReleaseData::fromArray($multipleAssetsData); + + expect($release->assets)->toHaveCount(3); +}); + +it('parses dates correctly', function () { + $release = ReleaseData::fromArray($this->sampleData); + + expect($release->created_at->format('Y-m-d'))->toBe('2024-01-15'); + expect($release->published_at->format('H:i:s'))->toBe('12:00:00'); +}); diff --git a/tests/Unit/Data/Repos/LicenseDataTest.php b/tests/Unit/Data/Repos/LicenseDataTest.php new file mode 100644 index 0000000..6a1a51b --- /dev/null +++ b/tests/Unit/Data/Repos/LicenseDataTest.php @@ -0,0 +1,68 @@ + 'mit', + 'name' => 'MIT License', + 'spdx_id' => 'MIT', + 'url' => 'https://api.github.com/licenses/mit', + 'node_id' => 'MDc6TGljZW5zZW1pdA==', + ]; + + $license = LicenseData::fromArray($data); + + expect($license->key)->toBe('mit'); + expect($license->name)->toBe('MIT License'); + expect($license->spdx_id)->toBe('MIT'); + expect($license->url)->toBe('https://api.github.com/licenses/mit'); + expect($license->node_id)->toBe('MDc6TGljZW5zZW1pdA=='); +}); + +it('can convert LicenseData to array', function () { + $license = new LicenseData( + key: 'apache-2.0', + name: 'Apache License 2.0', + spdx_id: 'Apache-2.0', + url: 'https://api.github.com/licenses/apache-2.0', + node_id: 'MDc6TGljZW5zZWFwYWNoZS0yLjA=', + ); + + $array = $license->toArray(); + + expect($array)->toBe([ + 'key' => 'apache-2.0', + 'name' => 'Apache License 2.0', + 'spdx_id' => 'Apache-2.0', + 'url' => 'https://api.github.com/licenses/apache-2.0', + 'node_id' => 'MDc6TGljZW5zZWFwYWNoZS0yLjA=', + ]); +}); + +it('handles null url', function () { + $data = [ + 'key' => 'other', + 'name' => 'Other', + 'spdx_id' => 'NOASSERTION', + 'node_id' => 'MDc6TGljZW5zZW90aGVy', + ]; + + $license = LicenseData::fromArray($data); + + expect($license->url)->toBeNull(); +}); + +it('handles common open source licenses', function () { + $licenses = [ + ['key' => 'gpl-3.0', 'name' => 'GNU General Public License v3.0', 'spdx_id' => 'GPL-3.0', 'url' => 'https://api.github.com/licenses/gpl-3.0', 'node_id' => 'gpl3'], + ['key' => 'bsd-3-clause', 'name' => 'BSD 3-Clause "New" or "Revised" License', 'spdx_id' => 'BSD-3-Clause', 'url' => 'https://api.github.com/licenses/bsd-3-clause', 'node_id' => 'bsd3'], + ['key' => 'isc', 'name' => 'ISC License', 'spdx_id' => 'ISC', 'url' => 'https://api.github.com/licenses/isc', 'node_id' => 'isc'], + ]; + + foreach ($licenses as $licenseData) { + $license = LicenseData::fromArray($licenseData); + expect($license->key)->toBe($licenseData['key']); + expect($license->spdx_id)->toBe($licenseData['spdx_id']); + } +}); diff --git a/tests/Unit/Data/RepoDataTest.php b/tests/Unit/Data/Repos/RepoDataTest.php similarity index 81% rename from tests/Unit/Data/RepoDataTest.php rename to tests/Unit/Data/Repos/RepoDataTest.php index a3e1e98..0e74b96 100644 --- a/tests/Unit/Data/RepoDataTest.php +++ b/tests/Unit/Data/Repos/RepoDataTest.php @@ -1,7 +1,10 @@ toBeArray(); expect($array['owner']['login'])->toBe('user'); }); + +it('handles null description', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + unset($data['description']); + + $repo = RepoData::fromArray($data); + + expect($repo->description)->toBeNull(); +}); + +it('handles visibility enum', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + $data['visibility'] = 'private'; + + $repo = RepoData::fromArray($data); + + expect($repo->visibility)->toBe(Visibility::PRIVATE); +}); + +it('handles license data', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + $data['license'] = [ + 'key' => 'mit', + 'name' => 'MIT License', + 'spdx_id' => 'MIT', + 'url' => 'https://api.github.com/licenses/mit', + 'node_id' => 'MDc6TGljZW5zZW1pdA==', + ]; + + $repo = RepoData::fromArray($data); + + expect($repo->license)->toBeInstanceOf(LicenseData::class); + expect($repo->license->key)->toBe('mit'); +}); + +it('handles topics array', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + $data['topics'] = ['php', 'laravel', 'github-api']; + + $repo = RepoData::fromArray($data); + + expect($repo->topics)->toBe(['php', 'laravel', 'github-api']); +}); + +it('handles private repository', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + $data['private'] = true; + + $repo = RepoData::fromArray($data); + + expect($repo->private)->toBeTrue(); +}); + +it('handles archived repository', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + $data['archived'] = true; + + $repo = RepoData::fromArray($data); + + expect($repo->archived)->toBeTrue(); +}); + +it('handles fork repository', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + $data['fork'] = true; + + $repo = RepoData::fromArray($data); + + expect($repo->fork)->toBeTrue(); +}); + +it('parses Carbon dates correctly', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + + $repo = RepoData::fromArray($data); + + expect($repo->created_at)->toBeInstanceOf(Carbon::class); + expect($repo->updated_at)->toBeInstanceOf(Carbon::class); + expect($repo->pushed_at)->toBeInstanceOf(Carbon::class); +}); + +it('handles missing permissions', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + unset($data['permissions']); + + $repo = RepoData::fromArray($data); + + expect($repo->permissions)->toBe([]); +}); + +it('handles is_template flag', function () { + $data = $this->createMockRepoData('test-repo', 1, 'testuser'); + $data['is_template'] = true; + + $repo = RepoData::fromArray($data); + + expect($repo->is_template)->toBeTrue(); +}); diff --git a/tests/Unit/Data/Repos/SearchRepositoriesDataTest.php b/tests/Unit/Data/Repos/SearchRepositoriesDataTest.php new file mode 100644 index 0000000..81ce2bc --- /dev/null +++ b/tests/Unit/Data/Repos/SearchRepositoriesDataTest.php @@ -0,0 +1,98 @@ + 100, + 'incomplete_results' => false, + 'items' => [ + $this->createMockRepoData('repo1', 1, 'owner1'), + $this->createMockRepoData('repo2', 2, 'owner2'), + ], + ]; + + $searchResults = SearchRepositoriesData::fromArray($data); + + expect($searchResults->total_count)->toBe(100); + expect($searchResults->incomplete_results)->toBeFalse(); + expect($searchResults->items)->toHaveCount(2); + expect($searchResults->items[0])->toBeInstanceOf(RepoData::class); + expect($searchResults->items[0]->name)->toBe('repo1'); + expect($searchResults->items[1])->toBeInstanceOf(RepoData::class); + expect($searchResults->items[1]->name)->toBe('repo2'); +}); + +it('can convert SearchRepositoriesData to array', function () { + $data = [ + 'total_count' => 50, + 'incomplete_results' => true, + 'items' => [ + $this->createMockRepoData('test-repo', 1, 'test-owner'), + ], + ]; + + $searchResults = SearchRepositoriesData::fromArray($data); + $array = $searchResults->toArray(); + + expect($array['total_count'])->toBe(50); + expect($array['incomplete_results'])->toBeTrue(); + expect($array['items'])->toHaveCount(1); + expect($array['items'][0]['name'])->toBe('test-repo'); +}); + +it('handles empty items array', function () { + $data = [ + 'total_count' => 0, + 'incomplete_results' => false, + 'items' => [], + ]; + + $searchResults = SearchRepositoriesData::fromArray($data); + + expect($searchResults->total_count)->toBe(0); + expect($searchResults->incomplete_results)->toBeFalse(); + expect($searchResults->items)->toBe([]); +}); + +it('handles missing items key', function () { + $data = [ + 'total_count' => 0, + 'incomplete_results' => false, + ]; + + $searchResults = SearchRepositoriesData::fromArray($data); + + expect($searchResults->items)->toBe([]); +}); + +it('handles incomplete results flag', function () { + $data = [ + 'total_count' => 1000, + 'incomplete_results' => true, + 'items' => [], + ]; + + $searchResults = SearchRepositoriesData::fromArray($data); + + expect($searchResults->incomplete_results)->toBeTrue(); +}); + +it('handles large result sets', function () { + $items = []; + for ($i = 1; $i <= 30; $i++) { + $items[] = $this->createMockRepoData("repo{$i}", $i, 'owner'); + } + + $data = [ + 'total_count' => 1500, + 'incomplete_results' => false, + 'items' => $items, + ]; + + $searchResults = SearchRepositoriesData::fromArray($data); + + expect($searchResults->total_count)->toBe(1500); + expect($searchResults->items)->toHaveCount(30); +}); diff --git a/tests/Unit/Data/TreeDataTest.php b/tests/Unit/Data/TreeDataTest.php new file mode 100644 index 0000000..96752d8 --- /dev/null +++ b/tests/Unit/Data/TreeDataTest.php @@ -0,0 +1,58 @@ + 'abc123def456789', + 'url' => 'https://api.github.com/repos/owner/repo/git/trees/abc123def456789', + ]; + + $tree = TreeData::fromArray($data); + + expect($tree->sha)->toBe('abc123def456789'); + expect($tree->url)->toBe('https://api.github.com/repos/owner/repo/git/trees/abc123def456789'); +}); + +it('can convert TreeData to array', function () { + $tree = new TreeData( + sha: 'xyz789', + url: 'https://api.github.com/repos/owner/repo/git/trees/xyz789', + ); + + $array = $tree->toArray(); + + expect($array)->toBe([ + 'sha' => 'xyz789', + 'url' => 'https://api.github.com/repos/owner/repo/git/trees/xyz789', + ]); +}); + +it('handles various SHA formats', function () { + $shas = [ + 'abc123def456789', + 'a' . str_repeat('0', 39), + str_repeat('f', 40), + ]; + + foreach ($shas as $sha) { + $data = [ + 'sha' => $sha, + 'url' => "https://api.github.com/repos/owner/repo/git/trees/{$sha}", + ]; + + $tree = TreeData::fromArray($data); + expect($tree->sha)->toBe($sha); + } +}); + +it('preserves URL format', function () { + $url = 'https://api.github.com/repos/special-org/my-repo/git/trees/abc123'; + + $tree = TreeData::fromArray([ + 'sha' => 'abc123', + 'url' => $url, + ]); + + expect($tree->url)->toBe($url); +}); diff --git a/tests/Unit/Data/VerificationDataTest.php b/tests/Unit/Data/VerificationDataTest.php new file mode 100644 index 0000000..94ce205 --- /dev/null +++ b/tests/Unit/Data/VerificationDataTest.php @@ -0,0 +1,110 @@ + true, + 'reason' => 'valid', + 'signature' => '-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----', + 'payload' => 'tree abc123\nparent def456\nauthor Test User', + 'verified_at' => '2024-01-15T10:00:00Z', + ]; + + $verification = VerificationData::fromArray($data); + + expect($verification->verified)->toBeTrue(); + expect($verification->reason)->toBe('valid'); + expect($verification->signature)->toContain('PGP SIGNATURE'); + expect($verification->payload)->toContain('tree abc123'); + expect($verification->verified_at)->toBe('2024-01-15T10:00:00Z'); +}); + +it('can convert VerificationData to array', function () { + $verification = new VerificationData( + verified: true, + reason: 'valid', + signature: 'gpg-signature', + payload: 'commit-payload', + verified_at: '2024-01-15T10:00:00Z', + ); + + $array = $verification->toArray(); + + expect($array)->toBe([ + 'verified' => true, + 'reason' => 'valid', + 'signature' => 'gpg-signature', + 'payload' => 'commit-payload', + 'verified_at' => '2024-01-15T10:00:00Z', + ]); +}); + +it('handles unverified signature', function () { + $data = [ + 'verified' => false, + 'reason' => 'unsigned', + ]; + + $verification = VerificationData::fromArray($data); + + expect($verification->verified)->toBeFalse(); + expect($verification->reason)->toBe('unsigned'); + expect($verification->signature)->toBeNull(); + expect($verification->payload)->toBeNull(); + expect($verification->verified_at)->toBeNull(); +}); + +it('handles various verification reasons', function () { + $reasons = [ + 'valid', + 'unsigned', + 'unknown_key', + 'bad_email', + 'unknown_signature_type', + 'no_user', + 'unverified_email', + 'bad_cert', + 'not_signing_key', + 'expired_key', + 'ocsp_revoked', + ]; + + foreach ($reasons as $reason) { + $data = [ + 'verified' => $reason === 'valid', + 'reason' => $reason, + ]; + + $verification = VerificationData::fromArray($data); + expect($verification->reason)->toBe($reason); + } +}); + +it('handles gpg_reject reason', function () { + $data = [ + 'verified' => false, + 'reason' => 'gpg_reject', + 'signature' => 'bad-signature', + 'payload' => null, + ]; + + $verification = VerificationData::fromArray($data); + + expect($verification->verified)->toBeFalse(); + expect($verification->reason)->toBe('gpg_reject'); + expect($verification->signature)->toBe('bad-signature'); +}); + +it('handles null optional fields', function () { + $data = [ + 'verified' => true, + 'reason' => 'valid', + ]; + + $verification = VerificationData::fromArray($data); + + expect($verification->signature)->toBeNull(); + expect($verification->payload)->toBeNull(); + expect($verification->verified_at)->toBeNull(); +}); From 96f04940655c192b11d9bd82f85a032820a399b7 Mon Sep 17 00:00:00 2001 From: Agent Bot Date: Thu, 1 Jan 2026 22:34:04 +0000 Subject: [PATCH 6/7] test: add comprehensive test coverage for Resources and Enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for: - All Enum classes (Direction, Sort, MergeMethod, Visibility) - Nested Enums (Issues/Sort, Issues/State, Pulls/Sort, Pulls/State, Repos/State, Repos/Type) - All Resource classes (ActionsResource, BaseResource, CommentsResource, CommitResource, FileResource, IssuesResource, PullRequestResource, PullRequestResourceEnhanced, ReleasesResource, RepoResource) Tests cover: - Enum case values and validation - from/tryFrom methods with valid and invalid inputs - Match expression compatibility - Array function compatibility - Type safety and comparisons Resource tests cover: - All public methods with complete mock data - Parameter validation - Pagination handling - Error handling scenarios - DTO conversion and type checking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/Enums/DirectionEnumTest.php | 51 ++ tests/Enums/Issues/IssueSortEnumTest.php | 62 ++ tests/Enums/Issues/IssueStateEnumTest.php | 72 +++ tests/Enums/MergeMethodEnumTest.php | 71 +++ tests/Enums/Pulls/PullsSortEnumTest.php | 84 +++ tests/Enums/Pulls/PullsStateEnumTest.php | 77 +++ tests/Enums/Repos/ReposStateEnumTest.php | 71 +++ tests/Enums/Repos/ReposTypeEnumTest.php | 116 ++++ tests/Enums/SortEnumTest.php | 68 +++ tests/Enums/VisibilityEnumTest.php | 71 +++ tests/Resources/ActionsResourceTest.php | 337 +++++++++++ tests/Resources/BaseResourceTest.php | 66 ++ tests/Resources/CommentsResourceTest.php | 230 +++++++ tests/Resources/CommitResourceTest.php | 267 ++++++++ tests/Resources/FileResourceTest.php | 150 +++++ tests/Resources/IssuesResourceTest.php | 376 ++++++++++++ .../PullRequestResourceEnhancedTest.php | 305 ++++++++++ tests/Resources/PullRequestResourceTest.php | 568 ++++++++++++++++++ tests/Resources/ReleasesResourceTest.php | 281 +++++++++ tests/Resources/RepoResourceTest.php | 339 +++++++++++ 20 files changed, 3662 insertions(+) create mode 100644 tests/Enums/DirectionEnumTest.php create mode 100644 tests/Enums/Issues/IssueSortEnumTest.php create mode 100644 tests/Enums/Issues/IssueStateEnumTest.php create mode 100644 tests/Enums/MergeMethodEnumTest.php create mode 100644 tests/Enums/Pulls/PullsSortEnumTest.php create mode 100644 tests/Enums/Pulls/PullsStateEnumTest.php create mode 100644 tests/Enums/Repos/ReposStateEnumTest.php create mode 100644 tests/Enums/Repos/ReposTypeEnumTest.php create mode 100644 tests/Enums/SortEnumTest.php create mode 100644 tests/Enums/VisibilityEnumTest.php create mode 100644 tests/Resources/ActionsResourceTest.php create mode 100644 tests/Resources/BaseResourceTest.php create mode 100644 tests/Resources/CommentsResourceTest.php create mode 100644 tests/Resources/CommitResourceTest.php create mode 100644 tests/Resources/FileResourceTest.php create mode 100644 tests/Resources/IssuesResourceTest.php create mode 100644 tests/Resources/PullRequestResourceEnhancedTest.php create mode 100644 tests/Resources/PullRequestResourceTest.php create mode 100644 tests/Resources/ReleasesResourceTest.php create mode 100644 tests/Resources/RepoResourceTest.php diff --git a/tests/Enums/DirectionEnumTest.php b/tests/Enums/DirectionEnumTest.php new file mode 100644 index 0000000..886a0e5 --- /dev/null +++ b/tests/Enums/DirectionEnumTest.php @@ -0,0 +1,51 @@ +value)->toBe('asc'); + }); + + it('has DESC case with correct value', function () { + expect(Direction::DESC->value)->toBe('desc'); + }); + + it('has exactly two cases', function () { + expect(Direction::cases()) + ->toHaveCount(2) + ->and(array_column(Direction::cases(), 'name')) + ->toContain('ASC', 'DESC'); + }); + + it('can be created from valid string values', function () { + expect(Direction::from('asc'))->toBe(Direction::ASC) + ->and(Direction::from('desc'))->toBe(Direction::DESC); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => Direction::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(Direction::tryFrom('invalid'))->toBeNull() + ->and(Direction::tryFrom('ASC'))->toBeNull() + ->and(Direction::tryFrom('DESC'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $result = match (Direction::ASC) { + Direction::ASC => 'ascending', + Direction::DESC => 'descending', + }; + + expect($result)->toBe('ascending'); + }); + + it('works with array functions', function () { + $values = array_map(fn (Direction $d) => $d->value, Direction::cases()); + + expect($values)->toBe(['asc', 'desc']); + }); +}); diff --git a/tests/Enums/Issues/IssueSortEnumTest.php b/tests/Enums/Issues/IssueSortEnumTest.php new file mode 100644 index 0000000..2afeeeb --- /dev/null +++ b/tests/Enums/Issues/IssueSortEnumTest.php @@ -0,0 +1,62 @@ +value)->toBe('created'); + }); + + it('has UPDATED case with correct value', function () { + expect(Sort::UPDATED->value)->toBe('updated'); + }); + + it('has COMMENTS case with correct value', function () { + expect(Sort::COMMENTS->value)->toBe('comments'); + }); + + it('has exactly three cases', function () { + expect(Sort::cases()) + ->toHaveCount(3) + ->and(array_column(Sort::cases(), 'name')) + ->toContain('CREATED', 'UPDATED', 'COMMENTS'); + }); + + it('can be created from valid string values', function () { + expect(Sort::from('created'))->toBe(Sort::CREATED) + ->and(Sort::from('updated'))->toBe(Sort::UPDATED) + ->and(Sort::from('comments'))->toBe(Sort::COMMENTS); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => Sort::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(Sort::tryFrom('invalid'))->toBeNull() + ->and(Sort::tryFrom('CREATED'))->toBeNull() + ->and(Sort::tryFrom('pushed'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $getSortLabel = fn (Sort $sort) => match ($sort) { + Sort::CREATED => 'Sort by creation date', + Sort::UPDATED => 'Sort by last update', + Sort::COMMENTS => 'Sort by comment count', + }; + + expect($getSortLabel(Sort::CREATED))->toBe('Sort by creation date') + ->and($getSortLabel(Sort::COMMENTS))->toBe('Sort by comment count'); + }); + + it('works with array functions', function () { + $values = array_map(fn (Sort $s) => $s->value, Sort::cases()); + + expect($values)->toBe(['created', 'updated', 'comments']); + }); + + it('is distinct from main Sort enum', function () { + expect(Sort::class)->toBe('JordanPartridge\GithubClient\Enums\Issues\Sort'); + }); +}); diff --git a/tests/Enums/Issues/IssueStateEnumTest.php b/tests/Enums/Issues/IssueStateEnumTest.php new file mode 100644 index 0000000..7874177 --- /dev/null +++ b/tests/Enums/Issues/IssueStateEnumTest.php @@ -0,0 +1,72 @@ +value)->toBe('open'); + }); + + it('has CLOSED case with correct value', function () { + expect(State::CLOSED->value)->toBe('closed'); + }); + + it('has ALL case with correct value', function () { + expect(State::ALL->value)->toBe('all'); + }); + + it('has exactly three cases', function () { + expect(State::cases()) + ->toHaveCount(3) + ->and(array_column(State::cases(), 'name')) + ->toContain('OPEN', 'CLOSED', 'ALL'); + }); + + it('can be created from valid string values', function () { + expect(State::from('open'))->toBe(State::OPEN) + ->and(State::from('closed'))->toBe(State::CLOSED) + ->and(State::from('all'))->toBe(State::ALL); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => State::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(State::tryFrom('invalid'))->toBeNull() + ->and(State::tryFrom('OPEN'))->toBeNull() + ->and(State::tryFrom('pending'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $getStateDescription = fn (State $state) => match ($state) { + State::OPEN => 'Open issues only', + State::CLOSED => 'Closed issues only', + State::ALL => 'All issues', + }; + + expect($getStateDescription(State::OPEN))->toBe('Open issues only') + ->and($getStateDescription(State::ALL))->toBe('All issues'); + }); + + it('works with array functions', function () { + $values = array_map(fn (State $s) => $s->value, State::cases()); + + expect($values)->toBe(['open', 'closed', 'all']); + }); + + it('is distinct from Pulls State enum', function () { + expect(State::class)->toBe('JordanPartridge\GithubClient\Enums\Issues\State'); + }); + + it('can filter to only active states', function () { + $activeStates = array_filter( + State::cases(), + fn (State $state) => $state !== State::ALL, + ); + + expect($activeStates)->toHaveCount(2) + ->and(array_column($activeStates, 'value'))->toContain('open', 'closed'); + }); +}); diff --git a/tests/Enums/MergeMethodEnumTest.php b/tests/Enums/MergeMethodEnumTest.php new file mode 100644 index 0000000..a7dd394 --- /dev/null +++ b/tests/Enums/MergeMethodEnumTest.php @@ -0,0 +1,71 @@ +value)->toBe('merge'); + }); + + it('has Squash case with correct value', function () { + expect(MergeMethod::Squash->value)->toBe('squash'); + }); + + it('has Rebase case with correct value', function () { + expect(MergeMethod::Rebase->value)->toBe('rebase'); + }); + + it('has exactly three cases', function () { + expect(MergeMethod::cases()) + ->toHaveCount(3) + ->and(array_column(MergeMethod::cases(), 'name')) + ->toContain('Merge', 'Squash', 'Rebase'); + }); + + it('can be created from valid string values', function () { + expect(MergeMethod::from('merge'))->toBe(MergeMethod::Merge) + ->and(MergeMethod::from('squash'))->toBe(MergeMethod::Squash) + ->and(MergeMethod::from('rebase'))->toBe(MergeMethod::Rebase); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => MergeMethod::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(MergeMethod::tryFrom('invalid'))->toBeNull() + ->and(MergeMethod::tryFrom('MERGE'))->toBeNull() + ->and(MergeMethod::tryFrom('Merge'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $getDescription = fn (MergeMethod $method) => match ($method) { + MergeMethod::Merge => 'Merge all commits', + MergeMethod::Squash => 'Squash all commits into one', + MergeMethod::Rebase => 'Rebase commits onto base branch', + }; + + expect($getDescription(MergeMethod::Merge))->toBe('Merge all commits') + ->and($getDescription(MergeMethod::Squash))->toBe('Squash all commits into one') + ->and($getDescription(MergeMethod::Rebase))->toBe('Rebase commits onto base branch'); + }); + + it('works with array functions', function () { + $values = array_map(fn (MergeMethod $m) => $m->value, MergeMethod::cases()); + + expect($values)->toBe(['merge', 'squash', 'rebase']); + }); + + it('can be compared with other MergeMethod values', function () { + expect(MergeMethod::Merge === MergeMethod::Merge)->toBeTrue() + ->and(MergeMethod::Merge === MergeMethod::Squash)->toBeFalse() + ->and(MergeMethod::Squash !== MergeMethod::Rebase)->toBeTrue(); + }); + + it('uses PascalCase for case names', function () { + foreach (MergeMethod::cases() as $case) { + expect($case->name)->toMatch('/^[A-Z][a-z]+$/'); + } + }); +}); diff --git a/tests/Enums/Pulls/PullsSortEnumTest.php b/tests/Enums/Pulls/PullsSortEnumTest.php new file mode 100644 index 0000000..e639d25 --- /dev/null +++ b/tests/Enums/Pulls/PullsSortEnumTest.php @@ -0,0 +1,84 @@ +value)->toBe('created'); + }); + + it('has UPDATED case with correct value', function () { + expect(Sort::UPDATED->value)->toBe('updated'); + }); + + it('has POPULARITY case with correct value', function () { + expect(Sort::POPULARITY->value)->toBe('popularity'); + }); + + it('has LONG_RUNNING case with correct value', function () { + expect(Sort::LONG_RUNNING->value)->toBe('long-running'); + }); + + it('has exactly four cases', function () { + expect(Sort::cases()) + ->toHaveCount(4) + ->and(array_column(Sort::cases(), 'name')) + ->toContain('CREATED', 'UPDATED', 'POPULARITY', 'LONG_RUNNING'); + }); + + it('can be created from valid string values', function () { + expect(Sort::from('created'))->toBe(Sort::CREATED) + ->and(Sort::from('updated'))->toBe(Sort::UPDATED) + ->and(Sort::from('popularity'))->toBe(Sort::POPULARITY) + ->and(Sort::from('long-running'))->toBe(Sort::LONG_RUNNING); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => Sort::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(Sort::tryFrom('invalid'))->toBeNull() + ->and(Sort::tryFrom('CREATED'))->toBeNull() + ->and(Sort::tryFrom('long_running'))->toBeNull(); + }); + + it('handles hyphenated value correctly', function () { + $sort = Sort::from('long-running'); + + expect($sort)->toBe(Sort::LONG_RUNNING) + ->and($sort->value)->toBe('long-running') + ->and($sort->name)->toBe('LONG_RUNNING'); + }); + + it('can be used in match expressions', function () { + $getSortLabel = fn (Sort $sort) => match ($sort) { + Sort::CREATED => 'Sort by creation date', + Sort::UPDATED => 'Sort by last update', + Sort::POPULARITY => 'Sort by popularity', + Sort::LONG_RUNNING => 'Sort by long running', + }; + + expect($getSortLabel(Sort::CREATED))->toBe('Sort by creation date') + ->and($getSortLabel(Sort::LONG_RUNNING))->toBe('Sort by long running'); + }); + + it('works with array functions', function () { + $values = array_map(fn (Sort $s) => $s->value, Sort::cases()); + + expect($values)->toBe(['created', 'updated', 'popularity', 'long-running']); + }); + + it('is distinct from Issues Sort enum', function () { + expect(Sort::class)->toBe('JordanPartridge\GithubClient\Enums\Pulls\Sort'); + }); + + it('has different cases than Issues Sort', function () { + $pullsSortValues = array_column(Sort::cases(), 'value'); + + expect($pullsSortValues) + ->toContain('popularity', 'long-running') + ->not->toContain('comments'); + }); +}); diff --git a/tests/Enums/Pulls/PullsStateEnumTest.php b/tests/Enums/Pulls/PullsStateEnumTest.php new file mode 100644 index 0000000..e00c65c --- /dev/null +++ b/tests/Enums/Pulls/PullsStateEnumTest.php @@ -0,0 +1,77 @@ +value)->toBe('open'); + }); + + it('has CLOSED case with correct value', function () { + expect(State::CLOSED->value)->toBe('closed'); + }); + + it('has ALL case with correct value', function () { + expect(State::ALL->value)->toBe('all'); + }); + + it('has exactly three cases', function () { + expect(State::cases()) + ->toHaveCount(3) + ->and(array_column(State::cases(), 'name')) + ->toContain('OPEN', 'CLOSED', 'ALL'); + }); + + it('can be created from valid string values', function () { + expect(State::from('open'))->toBe(State::OPEN) + ->and(State::from('closed'))->toBe(State::CLOSED) + ->and(State::from('all'))->toBe(State::ALL); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => State::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(State::tryFrom('invalid'))->toBeNull() + ->and(State::tryFrom('OPEN'))->toBeNull() + ->and(State::tryFrom('merged'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $getStateDescription = fn (State $state) => match ($state) { + State::OPEN => 'Open pull requests', + State::CLOSED => 'Closed pull requests', + State::ALL => 'All pull requests', + }; + + expect($getStateDescription(State::OPEN))->toBe('Open pull requests') + ->and($getStateDescription(State::ALL))->toBe('All pull requests'); + }); + + it('works with array functions', function () { + $values = array_map(fn (State $s) => $s->value, State::cases()); + + expect($values)->toBe(['open', 'closed', 'all']); + }); + + it('is distinct from Issues State enum', function () { + expect(State::class)->toBe('JordanPartridge\GithubClient\Enums\Pulls\State'); + }); + + it('has same case values as Issues State', function () { + $pullsStateValues = array_column(State::cases(), 'value'); + + expect($pullsStateValues)->toBe(['open', 'closed', 'all']); + }); + + it('can filter to only terminal states', function () { + $terminalStates = array_filter( + State::cases(), + fn (State $state) => $state !== State::ALL, + ); + + expect($terminalStates)->toHaveCount(2); + }); +}); diff --git a/tests/Enums/Repos/ReposStateEnumTest.php b/tests/Enums/Repos/ReposStateEnumTest.php new file mode 100644 index 0000000..1cc2d9d --- /dev/null +++ b/tests/Enums/Repos/ReposStateEnumTest.php @@ -0,0 +1,71 @@ +name)->toBe('OPEN'); + }); + + it('has CLOSED case', function () { + expect(State::CLOSED->name)->toBe('CLOSED'); + }); + + it('has ALL case', function () { + expect(State::ALL->name)->toBe('ALL'); + }); + + it('has exactly three cases', function () { + expect(State::cases()) + ->toHaveCount(3) + ->and(array_column(State::cases(), 'name')) + ->toContain('OPEN', 'CLOSED', 'ALL'); + }); + + it('is a unit enum without values', function () { + $case = State::OPEN; + + expect($case)->toBeInstanceOf(State::class) + ->and(property_exists($case, 'value'))->toBeFalse(); + }); + + it('can be used in match expressions', function () { + $getStateLabel = fn (State $state) => match ($state) { + State::OPEN => 'Open items', + State::CLOSED => 'Closed items', + State::ALL => 'All items', + }; + + expect($getStateLabel(State::OPEN))->toBe('Open items') + ->and($getStateLabel(State::ALL))->toBe('All items'); + }); + + it('works with array functions', function () { + $names = array_map(fn (State $s) => $s->name, State::cases()); + + expect($names)->toBe(['OPEN', 'CLOSED', 'ALL']); + }); + + it('is distinct from Issues and Pulls State enums', function () { + expect(State::class)->toBe('JordanPartridge\GithubClient\Enums\Repos\State'); + }); + + it('can filter to only terminal states', function () { + $terminalStates = array_filter( + State::cases(), + fn (State $state) => $state !== State::ALL, + ); + + expect($terminalStates)->toHaveCount(2); + }); + + it('can be compared for equality', function () { + expect(State::OPEN === State::OPEN)->toBeTrue() + ->and(State::OPEN === State::CLOSED)->toBeFalse(); + }); + + it('does not have from or tryFrom methods since it is a unit enum', function () { + expect(method_exists(State::class, 'from'))->toBeFalse() + ->and(method_exists(State::class, 'tryFrom'))->toBeFalse(); + }); +}); diff --git a/tests/Enums/Repos/ReposTypeEnumTest.php b/tests/Enums/Repos/ReposTypeEnumTest.php new file mode 100644 index 0000000..1ffebf0 --- /dev/null +++ b/tests/Enums/Repos/ReposTypeEnumTest.php @@ -0,0 +1,116 @@ +value)->toBe('all'); + }); + + it('has Public case with correct value', function () { + expect(Type::Public->value)->toBe('public'); + }); + + it('has Private case with correct value', function () { + expect(Type::Private->value)->toBe('private'); + }); + + it('has Forks case with correct value', function () { + expect(Type::Forks->value)->toBe('forks'); + }); + + it('has Sources case with correct value', function () { + expect(Type::Sources->value)->toBe('sources'); + }); + + it('has Member case with correct value', function () { + expect(Type::Member->value)->toBe('member'); + }); + + it('has Owner case with correct value', function () { + expect(Type::Owner->value)->toBe('owner'); + }); + + it('has exactly seven cases', function () { + expect(Type::cases()) + ->toHaveCount(7) + ->and(array_column(Type::cases(), 'name')) + ->toContain('All', 'Public', 'Private', 'Forks', 'Sources', 'Member', 'Owner'); + }); + + it('can be created from valid string values', function () { + expect(Type::from('all'))->toBe(Type::All) + ->and(Type::from('public'))->toBe(Type::Public) + ->and(Type::from('private'))->toBe(Type::Private) + ->and(Type::from('forks'))->toBe(Type::Forks) + ->and(Type::from('sources'))->toBe(Type::Sources) + ->and(Type::from('member'))->toBe(Type::Member) + ->and(Type::from('owner'))->toBe(Type::Owner); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => Type::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(Type::tryFrom('invalid'))->toBeNull() + ->and(Type::tryFrom('All'))->toBeNull() + ->and(Type::tryFrom('PUBLIC'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $getTypeDescription = fn (Type $type) => match ($type) { + Type::All => 'All repositories', + Type::Public => 'Public repositories only', + Type::Private => 'Private repositories only', + Type::Forks => 'Forked repositories only', + Type::Sources => 'Source repositories only', + Type::Member => 'Member repositories', + Type::Owner => 'Owner repositories', + }; + + expect($getTypeDescription(Type::All))->toBe('All repositories') + ->and($getTypeDescription(Type::Forks))->toBe('Forked repositories only') + ->and($getTypeDescription(Type::Owner))->toBe('Owner repositories'); + }); + + it('works with array functions', function () { + $values = array_map(fn (Type $t) => $t->value, Type::cases()); + + expect($values)->toBe(['all', 'public', 'private', 'forks', 'sources', 'member', 'owner']); + }); + + it('uses PascalCase for case names', function () { + foreach (Type::cases() as $case) { + expect($case->name)->toMatch('/^[A-Z][a-z]*$/'); + } + }); + + it('can filter visibility-related types', function () { + $visibilityTypes = array_filter( + Type::cases(), + fn (Type $type) => in_array($type, [Type::Public, Type::Private]), + ); + + expect($visibilityTypes)->toHaveCount(2); + }); + + it('can filter ownership-related types', function () { + $ownershipTypes = array_filter( + Type::cases(), + fn (Type $type) => in_array($type, [Type::Member, Type::Owner]), + ); + + expect($ownershipTypes)->toHaveCount(2); + }); + + it('can filter origin-related types', function () { + $originTypes = array_filter( + Type::cases(), + fn (Type $type) => in_array($type, [Type::Forks, Type::Sources]), + ); + + expect($originTypes)->toHaveCount(2); + }); +}); diff --git a/tests/Enums/SortEnumTest.php b/tests/Enums/SortEnumTest.php new file mode 100644 index 0000000..ab0e4ee --- /dev/null +++ b/tests/Enums/SortEnumTest.php @@ -0,0 +1,68 @@ +value)->toBe('created'); + }); + + it('has UPDATED case with correct value', function () { + expect(Sort::UPDATED->value)->toBe('updated'); + }); + + it('has PUSHED case with correct value', function () { + expect(Sort::PUSHED->value)->toBe('pushed'); + }); + + it('has FULL_NAME case with correct value', function () { + expect(Sort::FULL_NAME->value)->toBe('full_name'); + }); + + it('has exactly four cases', function () { + expect(Sort::cases()) + ->toHaveCount(4) + ->and(array_column(Sort::cases(), 'name')) + ->toContain('CREATED', 'UPDATED', 'PUSHED', 'FULL_NAME'); + }); + + it('can be created from valid string values', function () { + expect(Sort::from('created'))->toBe(Sort::CREATED) + ->and(Sort::from('updated'))->toBe(Sort::UPDATED) + ->and(Sort::from('pushed'))->toBe(Sort::PUSHED) + ->and(Sort::from('full_name'))->toBe(Sort::FULL_NAME); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => Sort::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(Sort::tryFrom('invalid'))->toBeNull() + ->and(Sort::tryFrom('CREATED'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $getSortLabel = fn (Sort $sort) => match ($sort) { + Sort::CREATED => 'Creation Date', + Sort::UPDATED => 'Last Updated', + Sort::PUSHED => 'Last Pushed', + Sort::FULL_NAME => 'Repository Name', + }; + + expect($getSortLabel(Sort::CREATED))->toBe('Creation Date') + ->and($getSortLabel(Sort::FULL_NAME))->toBe('Repository Name'); + }); + + it('works with array functions', function () { + $values = array_map(fn (Sort $s) => $s->value, Sort::cases()); + + expect($values)->toBe(['created', 'updated', 'pushed', 'full_name']); + }); + + it('can be compared with other Sort values', function () { + expect(Sort::CREATED === Sort::CREATED)->toBeTrue() + ->and(Sort::CREATED === Sort::UPDATED)->toBeFalse(); + }); +}); diff --git a/tests/Enums/VisibilityEnumTest.php b/tests/Enums/VisibilityEnumTest.php new file mode 100644 index 0000000..31a7343 --- /dev/null +++ b/tests/Enums/VisibilityEnumTest.php @@ -0,0 +1,71 @@ +value)->toBe('public'); + }); + + it('has PRIVATE case with correct value', function () { + expect(Visibility::PRIVATE->value)->toBe('private'); + }); + + it('has INTERNAL case with correct value', function () { + expect(Visibility::INTERNAL->value)->toBe('internal'); + }); + + it('has exactly three cases', function () { + expect(Visibility::cases()) + ->toHaveCount(3) + ->and(array_column(Visibility::cases(), 'name')) + ->toContain('PUBLIC', 'PRIVATE', 'INTERNAL'); + }); + + it('can be created from valid string values', function () { + expect(Visibility::from('public'))->toBe(Visibility::PUBLIC) + ->and(Visibility::from('private'))->toBe(Visibility::PRIVATE) + ->and(Visibility::from('internal'))->toBe(Visibility::INTERNAL); + }); + + it('throws ValueError for invalid string value', function () { + expect(fn () => Visibility::from('invalid')) + ->toThrow(ValueError::class); + }); + + it('returns null for invalid value with tryFrom', function () { + expect(Visibility::tryFrom('invalid'))->toBeNull() + ->and(Visibility::tryFrom('PUBLIC'))->toBeNull() + ->and(Visibility::tryFrom('Private'))->toBeNull(); + }); + + it('can be used in match expressions', function () { + $getDescription = fn (Visibility $v) => match ($v) { + Visibility::PUBLIC => 'Visible to everyone', + Visibility::PRIVATE => 'Visible only to collaborators', + Visibility::INTERNAL => 'Visible to organization members', + }; + + expect($getDescription(Visibility::PUBLIC))->toBe('Visible to everyone') + ->and($getDescription(Visibility::PRIVATE))->toBe('Visible only to collaborators') + ->and($getDescription(Visibility::INTERNAL))->toBe('Visible to organization members'); + }); + + it('works with array functions', function () { + $values = array_map(fn (Visibility $v) => $v->value, Visibility::cases()); + + expect($values)->toBe(['public', 'private', 'internal']); + }); + + it('can be compared with other Visibility values', function () { + expect(Visibility::PUBLIC === Visibility::PUBLIC)->toBeTrue() + ->and(Visibility::PUBLIC === Visibility::PRIVATE)->toBeFalse() + ->and(Visibility::PRIVATE !== Visibility::INTERNAL)->toBeTrue(); + }); + + it('uses SCREAMING_SNAKE_CASE for case names', function () { + foreach (Visibility::cases() as $case) { + expect($case->name)->toMatch('/^[A-Z]+$/'); + } + }); +}); diff --git a/tests/Resources/ActionsResourceTest.php b/tests/Resources/ActionsResourceTest.php new file mode 100644 index 0000000..961ff05 --- /dev/null +++ b/tests/Resources/ActionsResourceTest.php @@ -0,0 +1,337 @@ + 'fake-token']); +}); + +describe('ActionsResource comprehensive tests', function () { + it('can access actions resource through Github facade', function () { + $resource = Github::actions(); + + expect($resource)->toBeInstanceOf(ActionsResource::class); + }); + + describe('listWorkflows method', function () { + it('can list workflows with all parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 2, + 'workflows' => [ + [ + 'id' => 161335, + 'name' => 'CI', + 'path' => '.github/workflows/ci.yml', + 'state' => 'active', + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + ], + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->listWorkflows( + owner: 'owner', + repo: 'repo', + per_page: 50, + page: 1, + ); + + expect($response->status())->toBe(200) + ->and($response->json())->toHaveKey('workflows'); + }); + + it('handles empty workflows list', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 0, + 'workflows' => [], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->listWorkflows('owner', 'repo'); + + expect($response->json()['total_count'])->toBe(0) + ->and($response->json()['workflows'])->toBeEmpty(); + }); + + it('handles null pagination parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['workflows' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->listWorkflows( + owner: 'owner', + repo: 'repo', + per_page: null, + page: null, + ); + + expect($response->status())->toBe(200); + }); + }); + + describe('getWorkflowRuns method', function () { + it('can get workflow runs with all parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 1, + 'workflow_runs' => [ + [ + 'id' => 30433642, + 'name' => 'Build', + 'head_branch' => 'main', + 'head_sha' => 'abc123', + 'status' => 'completed', + 'conclusion' => 'success', + 'workflow_id' => 161335, + ], + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->getWorkflowRuns( + owner: 'owner', + repo: 'repo', + workflow_id: 161335, + per_page: 20, + page: 1, + status: 'completed', + conclusion: 'success', + branch: 'main', + ); + + expect($response->status())->toBe(200) + ->and($response->json())->toHaveKey('workflow_runs'); + }); + + it('handles all valid status values', function () { + $validStatuses = [ + 'completed', + 'action_required', + 'cancelled', + 'failure', + 'neutral', + 'skipped', + 'stale', + 'success', + 'timed_out', + 'in_progress', + 'queued', + 'requested', + 'waiting', + ]; + + foreach ($validStatuses as $status) { + $mockClient = new MockClient([ + '*' => MockResponse::make(['workflow_runs' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->getWorkflowRuns( + 'owner', + 'repo', + 161335, + status: $status, + ); + + expect($response->status())->toBe(200); + } + }); + + it('handles all valid conclusion values', function () { + $validConclusions = [ + 'action_required', + 'cancelled', + 'failure', + 'neutral', + 'success', + 'skipped', + 'stale', + 'timed_out', + ]; + + foreach ($validConclusions as $conclusion) { + $mockClient = new MockClient([ + '*' => MockResponse::make(['workflow_runs' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->getWorkflowRuns( + 'owner', + 'repo', + 161335, + conclusion: $conclusion, + ); + + expect($response->status())->toBe(200); + } + }); + + it('handles null optional parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['workflow_runs' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->getWorkflowRuns( + 'owner', + 'repo', + 161335, + per_page: null, + page: null, + status: null, + conclusion: null, + branch: null, + ); + + expect($response->status())->toBe(200); + }); + }); + + describe('triggerWorkflow method', function () { + it('can trigger workflow with ref only', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 204), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->triggerWorkflow( + 'owner', + 'repo', + 161335, + ['ref' => 'main'], + ); + + expect($response->status())->toBe(204); + }); + + it('can trigger workflow with inputs', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 204), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->triggerWorkflow( + 'owner', + 'repo', + 161335, + [ + 'ref' => 'main', + 'inputs' => [ + 'environment' => 'production', + 'debug' => 'false', + 'version' => '1.0.0', + ], + ], + ); + + expect($response->status())->toBe(204); + }); + + it('can trigger workflow with different refs', function () { + $refs = ['main', 'develop', 'feature/test', 'v1.0.0', 'refs/heads/main']; + + foreach ($refs as $ref) { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 204), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->triggerWorkflow( + 'owner', + 'repo', + 161335, + ['ref' => $ref], + ); + + expect($response->status())->toBe(204); + } + }); + }); + + describe('workflow states', function () { + it('can list active workflows', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'workflows' => [ + ['id' => 1, 'state' => 'active'], + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->listWorkflows('owner', 'repo'); + $workflows = $response->json()['workflows']; + + expect($workflows[0]['state'])->toBe('active'); + }); + + it('can list disabled workflows', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'workflows' => [ + ['id' => 1, 'state' => 'disabled_manually'], + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->listWorkflows('owner', 'repo'); + $workflows = $response->json()['workflows']; + + expect($workflows[0]['state'])->toBe('disabled_manually'); + }); + }); + + describe('error handling', function () { + it('handles repository not found', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'message' => 'Not Found', + 'documentation_url' => 'https://docs.github.com/rest', + ], 404), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->listWorkflows('nonexistent', 'repo'); + + expect($response->status())->toBe(404); + }); + + it('handles workflow not found', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'message' => 'Not Found', + ], 404), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::actions()->getWorkflowRuns('owner', 'repo', 999999); + + expect($response->status())->toBe(404); + }); + }); +}); diff --git a/tests/Resources/BaseResourceTest.php b/tests/Resources/BaseResourceTest.php new file mode 100644 index 0000000..a97af67 --- /dev/null +++ b/tests/Resources/BaseResourceTest.php @@ -0,0 +1,66 @@ + 'fake-token']); +}); + +describe('BaseResource', function () { + it('provides access to the Github instance', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $resource = Github::repos(); + + expect($resource)->toBeInstanceOf(RepoResource::class) + ->and($resource->github())->toBeInstanceOf(GithubClient::class); + }); + + it('provides access to the connector via convenience method', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $resource = Github::repos(); + + expect($resource->connector())->not->toBeNull(); + }); + + it('is readonly', function () { + $reflection = new ReflectionClass(BaseResource::class); + + expect($reflection->isReadOnly())->toBeTrue(); + }); + + it('implements ResourceInterface', function () { + $reflection = new ReflectionClass(BaseResource::class); + $interfaces = $reflection->getInterfaces(); + + expect(array_keys($interfaces)) + ->toContain('JordanPartridge\GithubClient\Contracts\ResourceInterface'); + }); + + it('is abstract', function () { + $reflection = new ReflectionClass(BaseResource::class); + + expect($reflection->isAbstract())->toBeTrue(); + }); + + it('has private github property', function () { + $reflection = new ReflectionClass(BaseResource::class); + $property = $reflection->getProperty('github'); + + expect($property->isPrivate())->toBeTrue(); + }); +}); diff --git a/tests/Resources/CommentsResourceTest.php b/tests/Resources/CommentsResourceTest.php new file mode 100644 index 0000000..671a3bd --- /dev/null +++ b/tests/Resources/CommentsResourceTest.php @@ -0,0 +1,230 @@ + 'fake-token']); + + $this->mockCommentData = [ + 'id' => 1, + 'node_id' => 'abc123', + 'path' => 'src/test.php', + 'position' => 5, + 'original_position' => 5, + 'commit_id' => 'abc123def456', + 'original_commit_id' => 'abc123def456', + 'user' => $this->createMockUserData('commenter', 1), + 'body' => 'Test comment body', + 'html_url' => 'https://github.com/owner/repo/pull/1#discussion_r1', + 'pull_request_url' => 'https://api.github.com/repos/owner/repo/pulls/1', + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + ]; +}); + +describe('CommentsResource', function () { + it('can access comments resource through Github facade', function () { + $resource = Github::comments(); + + expect($resource)->toBeInstanceOf(CommentsResource::class); + }); + + describe('forPullRequest method', function () { + it('requires repository to be specified in filters', function () { + expect(fn () => Github::comments()->forPullRequest(42)) + ->toThrow(InvalidArgumentException::class, 'Repository must be specified'); + }); + + it('can fetch comments with repository filter', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->forPullRequest(42, [ + 'repository' => 'owner/repo', + ]); + + expect($comments) + ->toBeArray() + ->toHaveCount(1); + }); + }); + + describe('byAuthor method', function () { + it('can filter comments by author', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->byAuthor(42, 'commenter', [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('byAuthorType method', function () { + it('can filter comments by bot author type', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->byAuthorType(42, 'bot', [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + + it('can filter comments by human author type', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->byAuthorType(42, 'human', [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('bySeverity method', function () { + it('can filter comments by high severity', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->bySeverity(42, 'high', [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + + it('can filter comments by medium severity', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->bySeverity(42, 'medium', [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + + it('can filter comments by low severity', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->bySeverity(42, 'low', [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('forFile method', function () { + it('can filter comments by file path', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->forFile(42, 'src/test.php', [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('codeRabbit method', function () { + it('fetches CodeRabbit AI comments', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->codeRabbit(42, [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('bots method', function () { + it('fetches all bot comments', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->bots(42, [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('humans method', function () { + it('fetches all human comments', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->humans(42, [ + 'repository' => 'owner/repo', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('filter chaining', function () { + it('can combine multiple filter options', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->mockCommentData], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::comments()->forPullRequest(42, [ + 'repository' => 'owner/repo', + 'author' => 'coderabbitai', + 'severity' => 'high', + 'file_path' => 'src/test.php', + ]); + + expect($comments)->toBeArray(); + }); + }); +}); diff --git a/tests/Resources/CommitResourceTest.php b/tests/Resources/CommitResourceTest.php new file mode 100644 index 0000000..caf4554 --- /dev/null +++ b/tests/Resources/CommitResourceTest.php @@ -0,0 +1,267 @@ + 'fake-token']); + + $this->mockCommitData = function (array $overrides = []) { + $sha = $overrides['sha'] ?? '1234567890123456789012345678901234567890'; + $date = Carbon::now()->toIso8601String(); + + return array_merge([ + 'sha' => $sha, + 'node_id' => 'MDQ6QmxvYjE0ODMzNDY0NjpkZjE5N2EzZTgwMjhmN2E5ODM3MDc2M2ZlN2EzNWFlYjYzOTMxOGExOg==', + 'url' => "https://api.github.com/repos/owner/repo/git/commits/{$sha}", + 'html_url' => "https://github.com/owner/repo/commit/{$sha}", + 'comments_url' => "https://api.github.com/repos/owner/repo/commits/{$sha}/comments", + 'commit' => [ + 'author' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'date' => $date, + ], + 'committer' => [ + 'name' => 'GitHub', + 'email' => 'noreply@github.com', + 'date' => $date, + ], + 'message' => $overrides['message'] ?? 'Test commit message', + 'tree' => [ + 'sha' => '0987654321098765432109876543210987654321', + 'url' => 'https://api.github.com/repos/owner/repo/git/trees/0987654321098765432109876543210987654321', + ], + 'url' => "https://api.github.com/repos/owner/repo/git/commits/{$sha}", + 'comment_count' => 0, + 'verification' => [ + 'verified' => true, + 'reason' => 'valid', + 'signature' => 'signature', + 'payload' => 'payload', + ], + ], + 'author' => $this->createMockUserData('john', 1), + 'committer' => $this->createMockUserData('github', 2), + 'parents' => [], + 'stats' => [ + 'additions' => 10, + 'deletions' => 5, + 'total' => 15, + ], + 'files' => [], + ], $overrides); + }; +}); + +describe('CommitResource comprehensive tests', function () { + it('can access commits resource through Github facade', function () { + $resource = Github::commits(); + + expect($resource)->toBeInstanceOf(CommitResource::class); + }); + + describe('all method', function () { + it('returns array of commits', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + ($this->mockCommitData)(['sha' => 'abc1234567890123456789012345678901234567']), + ($this->mockCommitData)(['sha' => 'def1234567890123456789012345678901234567']), + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commits = Github::commits()->all('owner/repo'); + + expect($commits)->toBeArray()->toHaveCount(2); + }); + + it('accepts pagination parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->mockCommitData)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commits = Github::commits()->all('owner/repo', per_page: 50, page: 2); + + expect($commits)->toBeArray(); + }); + + it('validates repository name format', function () { + expect(fn () => Github::commits()->all('invalid-repo-name')) + ->toThrow(InvalidArgumentException::class); + }); + + it('handles empty commits list', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commits = Github::commits()->all('owner/repo'); + + expect($commits)->toBeArray()->toBeEmpty(); + }); + + it('uses default pagination values', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->mockCommitData)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + // Default values: per_page: 100, page: 1 + $commits = Github::commits()->all('owner/repo'); + + expect($commits)->toBeArray(); + }); + }); + + describe('get method', function () { + it('returns commit data for valid SHA', function () { + $sha = '1234567890123456789012345678901234567890'; + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockCommitData)(['sha' => $sha]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commit = Github::commits()->get('owner/repo', $sha); + + expect($commit)->toBeInstanceOf(CommitData::class) + ->and($commit->sha)->toBe($sha); + }); + + it('validates repository name format', function () { + expect(fn () => Github::commits()->get('invalid', 'abc')) + ->toThrow(InvalidArgumentException::class); + }); + + it('handles commit with files', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockCommitData)([ + 'files' => [ + [ + 'sha' => 'file123', + 'filename' => 'src/test.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + 'blob_url' => 'https://github.com/owner/repo/blob/abc/src/test.php', + 'raw_url' => 'https://github.com/owner/repo/raw/abc/src/test.php', + 'contents_url' => 'https://api.github.com/repos/owner/repo/contents/src/test.php?ref=abc', + ], + ], + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commit = Github::commits()->get( + 'owner/repo', + '1234567890123456789012345678901234567890', + ); + + expect($commit->files)->toBeArray(); + }); + + it('handles commit with stats', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockCommitData)([ + 'stats' => [ + 'additions' => 100, + 'deletions' => 50, + 'total' => 150, + ], + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commit = Github::commits()->get( + 'owner/repo', + '1234567890123456789012345678901234567890', + ); + + expect($commit->stats)->not->toBeNull(); + }); + + it('handles verified commit', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockCommitData)(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commit = Github::commits()->get( + 'owner/repo', + '1234567890123456789012345678901234567890', + ); + + expect($commit)->toBeInstanceOf(CommitData::class); + }); + }); + + describe('repository name validation', function () { + it('accepts valid owner/repo format', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->mockCommitData)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $commits = Github::commits()->all('jordanpartridge/github-client'); + + expect($commits)->toBeArray(); + }); + + it('rejects missing slash', function () { + expect(fn () => Github::commits()->all('invalidreponame')) + ->toThrow(InvalidArgumentException::class); + }); + + it('rejects empty owner', function () { + expect(fn () => Github::commits()->all('/repo')) + ->toThrow(InvalidArgumentException::class); + }); + + it('rejects empty repo name', function () { + expect(fn () => Github::commits()->all('owner/')) + ->toThrow(InvalidArgumentException::class); + }); + }); + + describe('uses ValidatesRepoName trait', function () { + it('uses the trait', function () { + $traits = class_uses(CommitResource::class); + + expect($traits)->toContain('JordanPartridge\GithubClient\Concerns\ValidatesRepoName'); + }); + }); + + describe('Repo value object integration', function () { + it('creates Repo value object from full name', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockCommitData)(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + // This internally uses Repo::fromFullName + $commit = Github::commits()->get( + 'jordanpartridge/github-client', + '1234567890123456789012345678901234567890', + ); + + expect($commit)->toBeInstanceOf(CommitData::class); + }); + }); +}); diff --git a/tests/Resources/FileResourceTest.php b/tests/Resources/FileResourceTest.php new file mode 100644 index 0000000..e707bea --- /dev/null +++ b/tests/Resources/FileResourceTest.php @@ -0,0 +1,150 @@ + 'fake-token']); +}); + +describe('FileResource', function () { + it('can access files resource through Github facade', function () { + $resource = Github::files(); + + expect($resource)->toBeInstanceOf(FileResource::class); + }); + + describe('all method', function () { + it('can fetch files for a valid commit', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'sha' => '1234567890123456789012345678901234567890', + 'url' => 'https://api.github.com/repos/owner/repo/git/commits/1234567890123456789012345678901234567890', + 'tree' => [ + 'sha' => '0987654321098765432109876543210987654321', + 'url' => 'https://api.github.com/repos/owner/repo/git/trees/0987654321098765432109876543210987654321', + ], + 'files' => [ + [ + 'sha' => 'abc123', + 'filename' => 'src/test.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + ], + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::files()->all( + 'owner/repo', + '1234567890123456789012345678901234567890', + ); + + expect($response->status())->toBe(200) + ->and($response->json())->toBeArray() + ->and($response->json())->toHaveKey('files'); + }); + + it('throws exception for invalid repository name format', function () { + expect(fn () => Github::files()->all( + 'invalid-repo-name', + '1234567890123456789012345678901234567890', + ))->toThrow(InvalidArgumentException::class); + }); + + it('throws exception for invalid commit SHA format', function () { + expect(fn () => Github::files()->all( + 'owner/repo', + 'invalid-sha', + ))->toThrow(InvalidArgumentException::class, 'Invalid commit SHA format'); + }); + + it('throws exception for commit SHA with invalid characters', function () { + expect(fn () => Github::files()->all( + 'owner/repo', + 'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ', + ))->toThrow(InvalidArgumentException::class, 'Invalid commit SHA format'); + }); + + it('throws exception for commit SHA that is too short', function () { + expect(fn () => Github::files()->all( + 'owner/repo', + '1234567890', + ))->toThrow(InvalidArgumentException::class, 'Invalid commit SHA format'); + }); + + it('throws exception for commit SHA that is too long', function () { + expect(fn () => Github::files()->all( + 'owner/repo', + '12345678901234567890123456789012345678901234567890', + ))->toThrow(InvalidArgumentException::class, 'Invalid commit SHA format'); + }); + + it('accepts valid 40-character lowercase hex SHA', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['files' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::files()->all( + 'owner/repo', + 'abcdef1234567890abcdef1234567890abcdef12', + ); + + expect($response->status())->toBe(200); + }); + + it('accepts valid 40-character uppercase hex SHA', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['files' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::files()->all( + 'owner/repo', + 'ABCDEF1234567890ABCDEF1234567890ABCDEF12', + ); + + expect($response->status())->toBe(200); + }); + + it('accepts valid mixed-case 40-character hex SHA', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['files' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::files()->all( + 'owner/repo', + 'AbCdEf1234567890AbCdEf1234567890AbCdEf12', + ); + + expect($response->status())->toBe(200); + }); + + it('uses Repo value object internally', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['files' => []], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + // This test verifies the internal use of Repo::fromFullName + $response = Github::files()->all( + 'jordanpartridge/github-client', + '1234567890123456789012345678901234567890', + ); + + expect($response->status())->toBe(200); + }); + }); +}); diff --git a/tests/Resources/IssuesResourceTest.php b/tests/Resources/IssuesResourceTest.php new file mode 100644 index 0000000..0f5cd75 --- /dev/null +++ b/tests/Resources/IssuesResourceTest.php @@ -0,0 +1,376 @@ + 'fake-token']); +}); + +describe('IssuesResource', function () { + it('can access issues resource through Github facade', function () { + $resource = Github::issues(); + + expect($resource)->toBeInstanceOf(IssuesResource::class); + }); + + describe('getComment method', function () { + it('can get a single comment by ID', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'id' => 123, + 'body' => 'Test comment', + 'user' => $this->createMockUserData('commenter', 1), + 'html_url' => 'https://github.com/test/repo/issues/1#issuecomment-123', + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comment = Github::issues()->getComment('test', 'repo', 123); + + expect($comment) + ->toBeInstanceOf(IssueCommentDTO::class) + ->and($comment->id)->toBe(123) + ->and($comment->body)->toBe('Test comment'); + }); + }); + + describe('updateComment method', function () { + it('can update a comment', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'id' => 123, + 'body' => 'Updated comment', + 'user' => $this->createMockUserData('commenter', 1), + 'html_url' => 'https://github.com/test/repo/issues/1#issuecomment-123', + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-02T00:00:00Z', + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comment = Github::issues()->updateComment('test', 'repo', 123, 'Updated comment'); + + expect($comment) + ->toBeInstanceOf(IssueCommentDTO::class) + ->and($comment->body)->toBe('Updated comment'); + }); + }); + + describe('deleteComment method', function () { + it('returns true on successful deletion', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 204), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::issues()->deleteComment('test', 'repo', 123); + + expect($result)->toBeTrue(); + }); + + it('returns false on failed deletion', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['message' => 'Not found'], 404), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::issues()->deleteComment('test', 'repo', 999); + + expect($result)->toBeFalse(); + }); + }); + + describe('comments method with pagination', function () { + it('accepts pagination parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + $this->createMockCommentData(['id' => 1]), + $this->createMockCommentData(['id' => 2]), + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::issues()->comments( + owner: 'test', + repo: 'repo', + issue_number: 1, + per_page: 50, + page: 2, + ); + + expect($comments)->toBeArray()->toHaveCount(2); + }); + + it('accepts since parameter', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockCommentData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::issues()->comments( + owner: 'test', + repo: 'repo', + issue_number: 1, + since: '2024-01-01T00:00:00Z', + ); + + expect($comments)->toBeArray(); + }); + }); + + describe('all method with filters', function () { + it('accepts all filter parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->all( + per_page: 50, + page: 1, + state: State::OPEN, + labels: 'bug,enhancement', + sort: Sort::CREATED, + direction: Direction::DESC, + assignee: 'testuser', + creator: 'testuser', + mentioned: 'testuser', + since: '2024-01-01T00:00:00Z', + ); + + expect($response->status())->toBe(200); + }); + }); + + describe('forRepo method with filters', function () { + it('accepts all filter parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->forRepo( + owner: 'test', + repo: 'repo', + per_page: 50, + page: 1, + state: State::CLOSED, + labels: 'bug', + sort: Sort::UPDATED, + direction: Direction::ASC, + assignee: 'testuser', + creator: 'testuser', + mentioned: 'testuser', + since: '2024-01-01T00:00:00Z', + ); + + expect($response->status())->toBe(200); + }); + }); + + describe('allForRepo method with filters', function () { + it('accepts all filter parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $issues = Github::issues()->allForRepo( + owner: 'test', + repo: 'repo', + per_page: 50, + state: State::ALL, + labels: 'bug', + sort: Sort::COMMENTS, + direction: Direction::DESC, + assignee: 'testuser', + creator: 'testuser', + mentioned: 'testuser', + since: '2024-01-01T00:00:00Z', + ); + + expect($issues)->toBeArray(); + }); + }); + + describe('create method with all parameters', function () { + it('can create issue with all optional parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make($this->createMockIssueData([ + 'title' => 'New Issue', + 'body' => 'Issue body', + 'assignees' => [$this->createMockUserData('testuser', 1)], + 'labels' => [['id' => 1, 'name' => 'bug', 'color' => 'ff0000', 'description' => 'Bug report', 'default' => false]], + ]), 201), + ]); + + Github::connector()->withMockClient($mockClient); + + $issue = Github::issues()->create( + owner: 'test', + repo: 'repo', + title: 'New Issue', + body: 'Issue body', + assignees: ['testuser'], + milestone: 1, + labels: ['bug'], + ); + + expect($issue) + ->toBeInstanceOf(IssueDTO::class) + ->and($issue->title)->toBe('New Issue'); + }); + }); + + describe('update method with all parameters', function () { + it('can update issue with all optional parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make($this->createMockIssueData([ + 'title' => 'Updated Title', + 'body' => 'Updated body', + 'state' => 'closed', + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $issue = Github::issues()->update( + owner: 'test', + repo: 'repo', + issue_number: 1, + title: 'Updated Title', + body: 'Updated body', + state: State::CLOSED, + assignees: ['testuser'], + milestone: 2, + labels: ['enhancement'], + ); + + expect($issue) + ->toBeInstanceOf(IssueDTO::class) + ->and($issue->title)->toBe('Updated Title') + ->and($issue->state)->toBe('closed'); + }); + }); + + describe('close and reopen methods', function () { + it('close method updates state to closed', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make($this->createMockIssueData(['state' => 'closed']), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $issue = Github::issues()->close('test', 'repo', 1); + + expect($issue->state)->toBe('closed'); + }); + + it('reopen method updates state to open', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make($this->createMockIssueData(['state' => 'open']), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $issue = Github::issues()->reopen('test', 'repo', 1); + + expect($issue->state)->toBe('open'); + }); + }); + + describe('Sort enum usage', function () { + it('can sort by CREATED', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->forRepo('test', 'repo', sort: Sort::CREATED); + + expect($response->status())->toBe(200); + }); + + it('can sort by UPDATED', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->forRepo('test', 'repo', sort: Sort::UPDATED); + + expect($response->status())->toBe(200); + }); + + it('can sort by COMMENTS', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->forRepo('test', 'repo', sort: Sort::COMMENTS); + + expect($response->status())->toBe(200); + }); + }); + + describe('State enum usage', function () { + it('can filter by OPEN state', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData(['state' => 'open'])], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->forRepo('test', 'repo', state: State::OPEN); + + expect($response->status())->toBe(200); + }); + + it('can filter by CLOSED state', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData(['state' => 'closed'])], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->forRepo('test', 'repo', state: State::CLOSED); + + expect($response->status())->toBe(200); + }); + + it('can filter by ALL state', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockIssueData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::issues()->forRepo('test', 'repo', state: State::ALL); + + expect($response->status())->toBe(200); + }); + }); +}); diff --git a/tests/Resources/PullRequestResourceEnhancedTest.php b/tests/Resources/PullRequestResourceEnhancedTest.php new file mode 100644 index 0000000..43f3bea --- /dev/null +++ b/tests/Resources/PullRequestResourceEnhancedTest.php @@ -0,0 +1,305 @@ + 'fake-token']); + + $this->mockPRData = function (array $overrides = []) { + return array_merge([ + 'id' => 1, + 'number' => 1, + 'state' => 'open', + 'title' => 'Test Pull Request', + 'body' => 'This is a test pull request', + 'html_url' => 'https://github.com/test/repo/pull/1', + 'diff_url' => 'https://github.com/test/repo/pull/1.diff', + 'patch_url' => 'https://github.com/test/repo/pull/1.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature-branch'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'comments' => 5, + 'review_comments' => 3, + 'commits' => 2, + 'additions' => 10, + 'deletions' => 5, + 'changed_files' => 2, + 'user' => $this->createMockUserData('testuser', 1), + 'merged_by' => null, + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + 'closed_at' => null, + ], $overrides); + }; + + $this->mockPRListData = function (array $overrides = []) { + return array_merge([ + 'id' => 1, + 'number' => 1, + 'state' => 'open', + 'title' => 'Test Pull Request', + 'body' => 'This is a test pull request', + 'html_url' => 'https://github.com/test/repo/pull/1', + 'diff_url' => 'https://github.com/test/repo/pull/1.diff', + 'patch_url' => 'https://github.com/test/repo/pull/1.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature-branch'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'user' => $this->createMockUserData('testuser', 1), + 'merged_by' => null, + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + 'closed_at' => null, + ], $overrides); + }; +}); + +describe('PullRequestResourceEnhanced', function () { + it('extends PullRequestResource', function () { + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + + expect($resource)->toBeInstanceOf(PullRequestResourceEnhanced::class); + }); + + describe('allWithCommentCounts method', function () { + it('fetches PRs with complete data including comment counts', function () { + // First response: list of PRs + $listResponse = MockResponse::make([ + ($this->mockPRListData)(['number' => 1]), + ($this->mockPRListData)(['number' => 2]), + ], 200); + + // Subsequent responses: detailed PR data + $detail1 = MockResponse::make(($this->mockPRData)(['number' => 1, 'comments' => 5]), 200); + $detail2 = MockResponse::make(($this->mockPRData)(['number' => 2, 'comments' => 10]), 200); + + $mockClient = new MockClient(); + $mockClient->addResponse($listResponse); + $mockClient->addResponse($detail1); + $mockClient->addResponse($detail2); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->allWithCommentCounts('test', 'repo'); + + expect($prs) + ->toBeArray() + ->toHaveCount(2) + ->and($prs[0])->toBeInstanceOf(PullRequestDTO::class); + }); + + it('limits to maxPRs parameter', function () { + // Create list of 5 PRs + $prList = []; + for ($i = 1; $i <= 5; $i++) { + $prList[] = ($this->mockPRListData)(['number' => $i]); + } + + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make($prList, 200)); + + // Only 3 detailed requests should be made + for ($i = 1; $i <= 3; $i++) { + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => $i]), 200)); + } + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->allWithCommentCounts('test', 'repo', [], 3); + + expect($prs)->toHaveCount(3); + }); + + it('accepts query parameters', function () { + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make([($this->mockPRListData)()], 200)); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(), 200)); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->allWithCommentCounts('test', 'repo', [ + 'state' => 'open', + 'sort' => 'updated', + ]); + + expect($prs)->toBeArray(); + }); + }); + + describe('getMultipleWithCommentCounts method', function () { + it('fetches specific PRs by number', function () { + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => 5]), 200)); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => 10]), 200)); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => 15]), 200)); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->getMultipleWithCommentCounts('test', 'repo', [5, 10, 15]); + + expect($prs) + ->toBeArray() + ->toHaveCount(3); + }); + + it('skips PRs that cannot be fetched', function () { + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => 1]), 200)); + $mockClient->addResponse(MockResponse::make(['message' => 'Not Found'], 404)); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => 3]), 200)); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->getMultipleWithCommentCounts('test', 'repo', [1, 2, 3]); + + // Should only have 2 PRs since #2 failed + expect($prs)->toHaveCount(2); + }); + + it('handles empty PR list', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->getMultipleWithCommentCounts('test', 'repo', []); + + expect($prs) + ->toBeArray() + ->toBeEmpty(); + }); + }); + + describe('recentWithCommentCounts method', function () { + it('fetches recent PRs with default limit of 5', function () { + $prList = []; + for ($i = 1; $i <= 10; $i++) { + $prList[] = ($this->mockPRListData)(['number' => $i]); + } + + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make($prList, 200)); + for ($i = 1; $i <= 5; $i++) { + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => $i]), 200)); + } + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->recentWithCommentCounts('test', 'repo'); + + expect($prs)->toHaveCount(5); + }); + + it('respects custom limit parameter', function () { + $prList = []; + for ($i = 1; $i <= 10; $i++) { + $prList[] = ($this->mockPRListData)(['number' => $i]); + } + + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make($prList, 200)); + for ($i = 1; $i <= 3; $i++) { + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => $i]), 200)); + } + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->recentWithCommentCounts('test', 'repo', 3); + + expect($prs)->toHaveCount(3); + }); + + it('caps limit at 20', function () { + $prList = []; + for ($i = 1; $i <= 30; $i++) { + $prList[] = ($this->mockPRListData)(['number' => $i]); + } + + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make($prList, 200)); + for ($i = 1; $i <= 20; $i++) { + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => $i]), 200)); + } + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->recentWithCommentCounts('test', 'repo', 50); + + expect(count($prs))->toBeLessThanOrEqual(20); + }); + + it('filters by state', function () { + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make([($this->mockPRListData)(['state' => 'closed'])], 200)); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['state' => 'closed']), 200)); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->recentWithCommentCounts('test', 'repo', 5, 'closed'); + + expect($prs)->toBeArray(); + }); + + it('sorts by updated date descending', function () { + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make([($this->mockPRListData)()], 200)); + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(), 200)); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->recentWithCommentCounts('test', 'repo'); + + expect($prs)->toBeArray(); + }); + }); + + describe('inherits from PullRequestResource', function () { + it('can use parent all method', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->mockPRListData)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $prs = $resource->all('test', 'repo'); + + expect($prs)->toBeArray(); + }); + + it('can use parent get method', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockPRData)(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $resource = new PullRequestResourceEnhanced(app(\JordanPartridge\GithubClient\Github::class)); + $pr = $resource->get('test', 'repo', 1); + + expect($pr)->toBeInstanceOf(PullRequestDTO::class); + }); + }); +}); diff --git a/tests/Resources/PullRequestResourceTest.php b/tests/Resources/PullRequestResourceTest.php new file mode 100644 index 0000000..1a1ad73 --- /dev/null +++ b/tests/Resources/PullRequestResourceTest.php @@ -0,0 +1,568 @@ + 'fake-token']); + + $this->mockPRData = function (array $overrides = []) { + return array_merge([ + 'id' => 1, + 'number' => 1, + 'state' => 'open', + 'title' => 'Test Pull Request', + 'body' => 'This is a test pull request', + 'html_url' => 'https://github.com/test/repo/pull/1', + 'diff_url' => 'https://github.com/test/repo/pull/1.diff', + 'patch_url' => 'https://github.com/test/repo/pull/1.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature-branch'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'comments' => 5, + 'review_comments' => 3, + 'commits' => 2, + 'additions' => 10, + 'deletions' => 5, + 'changed_files' => 2, + 'user' => $this->createMockUserData('testuser', 1), + 'merged_by' => null, + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + 'closed_at' => null, + ], $overrides); + }; +}); + +describe('PullRequestResource', function () { + it('can access pullRequests resource through Github facade', function () { + $resource = Github::pullRequests(); + + expect($resource)->toBeInstanceOf(PullRequestResource::class); + }); + + describe('getComment method', function () { + it('can get a single comment by ID', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'id' => 123, + 'node_id' => 'abc123', + 'path' => 'src/test.php', + 'position' => 5, + 'original_position' => 5, + 'commit_id' => 'abc123def456', + 'original_commit_id' => 'abc123def456', + 'user' => $this->createMockUserData('commenter', 3), + 'body' => 'Specific comment', + 'html_url' => 'https://github.com/test/repo/pull/1#discussion_r123', + 'pull_request_url' => 'https://api.github.com/repos/test/repo/pulls/1', + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comment = Github::pullRequests()->getComment('test', 'repo', 123); + + expect($comment) + ->toBeInstanceOf(PullRequestCommentDTO::class) + ->and($comment->id)->toBe(123); + }); + }); + + describe('updateComment method', function () { + it('can update a comment', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'id' => 123, + 'node_id' => 'abc123', + 'path' => 'src/test.php', + 'position' => 5, + 'original_position' => 5, + 'commit_id' => 'abc123def456', + 'original_commit_id' => 'abc123def456', + 'user' => $this->createMockUserData('commenter', 3), + 'body' => 'Updated comment body', + 'html_url' => 'https://github.com/test/repo/pull/1#discussion_r123', + 'pull_request_url' => 'https://api.github.com/repos/test/repo/pulls/1', + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-02T00:00:00Z', + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comment = Github::pullRequests()->updateComment('test', 'repo', 123, 'Updated comment body'); + + expect($comment) + ->toBeInstanceOf(PullRequestCommentDTO::class) + ->and($comment->body)->toBe('Updated comment body'); + }); + }); + + describe('deleteComment method', function () { + it('returns true on successful deletion', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 204), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::pullRequests()->deleteComment('test', 'repo', 123); + + expect($result)->toBeTrue(); + }); + + it('returns false on failed deletion', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['message' => 'Not found'], 404), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::pullRequests()->deleteComment('test', 'repo', 999); + + expect($result)->toBeFalse(); + }); + }); + + describe('commentsWithFilters method', function () { + it('can filter comments by author', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([[ + 'id' => 1, + 'node_id' => 'abc123', + 'path' => 'src/test.php', + 'position' => 5, + 'original_position' => 5, + 'commit_id' => 'abc123def456', + 'original_commit_id' => 'abc123def456', + 'user' => $this->createMockUserData('coderabbitai', 1), + 'body' => 'AI review comment', + 'html_url' => 'https://github.com/test/repo/pull/1#discussion_r1', + 'pull_request_url' => 'https://api.github.com/repos/test/repo/pulls/1', + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + ]], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::pullRequests()->commentsWithFilters('test', 'repo', 1, [ + 'author' => 'coderabbitai', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('forPullRequest method', function () { + it('is an alias for commentsWithFilters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $comments = Github::pullRequests()->forPullRequest('test', 'repo', 1, [ + 'author' => 'coderabbitai', + ]); + + expect($comments)->toBeArray(); + }); + }); + + describe('summaries method', function () { + it('returns array of PullRequestSummaryDTO', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([[ + 'id' => 1, + 'number' => 1, + 'state' => 'open', + 'title' => 'Test PR', + 'body' => 'Test body', + 'html_url' => 'https://github.com/test/repo/pull/1', + 'diff_url' => 'https://github.com/test/repo/pull/1.diff', + 'patch_url' => 'https://github.com/test/repo/pull/1.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'user' => $this->createMockUserData(), + 'merged_by' => null, + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + 'closed_at' => null, + ]], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $summaries = Github::pullRequests()->summaries('test', 'repo'); + + expect($summaries) + ->toBeArray() + ->and($summaries[0])->toBeInstanceOf(PullRequestSummaryDTO::class); + }); + }); + + describe('detail method', function () { + it('returns PullRequestDetailDTO', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockPRData)(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $detail = Github::pullRequests()->detail('test', 'repo', 1); + + expect($detail)->toBeInstanceOf(PullRequestDetailDTO::class); + }); + }); + + describe('detailsForMultiple method', function () { + it('fetches details for multiple PRs', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockPRData)(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $details = Github::pullRequests()->detailsForMultiple('test', 'repo', [1, 2, 3]); + + expect($details) + ->toBeArray() + ->toHaveCount(3); + }); + + it('respects maxRequests limit', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->mockPRData)(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $details = Github::pullRequests()->detailsForMultiple( + 'test', + 'repo', + [1, 2, 3, 4, 5], + maxRequests: 2, + ); + + expect($details)->toHaveCount(2); + }); + + it('handles exceptions gracefully', function () { + $callCount = 0; + $mockClient = new MockClient([ + '*' => function () use (&$callCount) { + $callCount++; + if ($callCount === 2) { + return MockResponse::make(['message' => 'Not found'], 404); + } + + return MockResponse::make(($this->mockPRData)(), 200); + }, + ]); + + Github::connector()->withMockClient($mockClient); + + // This should not throw, just skip the failed PR + $details = Github::pullRequests()->detailsForMultiple('test', 'repo', [1, 2, 3]); + + expect($details)->toBeArray(); + }); + }); + + describe('recentDetails method', function () { + it('fetches recent PRs with details', function () { + $summaryResponse = MockResponse::make([[ + 'id' => 1, + 'number' => 1, + 'state' => 'open', + 'title' => 'Test PR', + 'body' => 'Test body', + 'html_url' => 'https://github.com/test/repo/pull/1', + 'diff_url' => 'https://github.com/test/repo/pull/1.diff', + 'patch_url' => 'https://github.com/test/repo/pull/1.patch', + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'user' => $this->createMockUserData(), + 'merged_by' => null, + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + 'closed_at' => null, + ]], 200); + + $detailResponse = MockResponse::make(($this->mockPRData)(), 200); + + $mockClient = new MockClient(); + $mockClient->addResponse($summaryResponse); + $mockClient->addResponse($detailResponse); + + Github::connector()->withMockClient($mockClient); + + $details = Github::pullRequests()->recentDetails('test', 'repo', 5, 'open'); + + expect($details)->toBeArray(); + }); + + it('limits per_page to 10 for rate limit protection', function () { + $summaries = []; + for ($i = 1; $i <= 10; $i++) { + $summaries[] = [ + 'id' => $i, + 'number' => $i, + 'state' => 'open', + 'title' => "PR $i", + 'body' => 'Test body', + 'html_url' => "https://github.com/test/repo/pull/$i", + 'diff_url' => "https://github.com/test/repo/pull/$i.diff", + 'patch_url' => "https://github.com/test/repo/pull/$i.patch", + 'base' => ['ref' => 'main'], + 'head' => ['ref' => 'feature'], + 'draft' => false, + 'merged' => false, + 'merged_at' => null, + 'merge_commit_sha' => null, + 'user' => $this->createMockUserData(), + 'merged_by' => null, + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + 'closed_at' => null, + ]; + } + + // First call returns summaries (max 10), subsequent calls return details + $mockClient = new MockClient(); + $mockClient->addResponse(MockResponse::make($summaries, 200)); + for ($i = 0; $i < 10; $i++) { + $mockClient->addResponse(MockResponse::make(($this->mockPRData)(['number' => $i + 1]), 200)); + } + + Github::connector()->withMockClient($mockClient); + + // Request 15 but per_page is capped at 10, so only 10 summaries returned + $details = Github::pullRequests()->recentDetails('test', 'repo', 15); + + // Since per_page is min($limit, 10) = 10, only 10 summaries returned + expect(count($details))->toBeLessThanOrEqual(10); + }); + }); + + describe('files method', function () { + it('returns array of PullRequestFileDTO', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + [ + 'sha' => 'abc123', + 'filename' => 'src/test.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + 'blob_url' => 'https://github.com/test/repo/blob/abc123/src/test.php', + 'raw_url' => 'https://github.com/test/repo/raw/abc123/src/test.php', + 'contents_url' => 'https://api.github.com/repos/test/repo/contents/src/test.php?ref=abc123', + 'patch' => '@@ -1,5 +1,10 @@', + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $files = Github::pullRequests()->files('test', 'repo', 1); + + expect($files) + ->toBeArray() + ->and($files[0])->toBeInstanceOf(PullRequestFileDTO::class); + }); + }); + + describe('diff method', function () { + it('returns analysis data with summary', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + [ + 'sha' => 'abc123', + 'filename' => 'src/test.php', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + 'blob_url' => 'https://github.com/test/repo/blob/abc123/src/test.php', + 'raw_url' => 'https://github.com/test/repo/raw/abc123/src/test.php', + 'contents_url' => 'https://api.github.com/repos/test/repo/contents/src/test.php?ref=abc123', + 'patch' => '@@ -1,5 +1,10 @@', + ], + [ + 'sha' => 'def456', + 'filename' => 'tests/TestCase.php', + 'status' => 'added', + 'additions' => 50, + 'deletions' => 0, + 'changes' => 50, + 'blob_url' => 'https://github.com/test/repo/blob/def456/tests/TestCase.php', + 'raw_url' => 'https://github.com/test/repo/raw/def456/tests/TestCase.php', + 'contents_url' => 'https://api.github.com/repos/test/repo/contents/tests/TestCase.php?ref=def456', + 'patch' => '@@ -0,0 +1,50 @@', + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $analysis = Github::pullRequests()->diff('test', 'repo', 1); + + expect($analysis) + ->toBeArray() + ->toHaveKeys(['summary', 'categories', 'files', 'analysis_tags']) + ->and($analysis['summary'])->toHaveKeys([ + 'total_files', + 'total_additions', + 'total_deletions', + 'total_changes', + 'large_changes', + 'new_files', + 'deleted_files', + 'modified_files', + 'renamed_files', + ]); + }); + + it('categorizes files correctly', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + [ + 'sha' => 'abc123', + 'filename' => 'tests/Feature/ExampleTest.php', + 'status' => 'added', + 'additions' => 20, + 'deletions' => 0, + 'changes' => 20, + 'blob_url' => 'https://github.com/test/repo/blob/abc123/tests/Feature/ExampleTest.php', + 'raw_url' => 'https://github.com/test/repo/raw/abc123/tests/Feature/ExampleTest.php', + 'contents_url' => 'https://api.github.com/repos/test/repo/contents/tests/Feature/ExampleTest.php?ref=abc123', + ], + [ + 'sha' => 'def456', + 'filename' => 'config/app.php', + 'status' => 'modified', + 'additions' => 5, + 'deletions' => 2, + 'changes' => 7, + 'blob_url' => 'https://github.com/test/repo/blob/def456/config/app.php', + 'raw_url' => 'https://github.com/test/repo/raw/def456/config/app.php', + 'contents_url' => 'https://api.github.com/repos/test/repo/contents/config/app.php?ref=def456', + ], + [ + 'sha' => 'ghi789', + 'filename' => 'README.md', + 'status' => 'modified', + 'additions' => 10, + 'deletions' => 5, + 'changes' => 15, + 'blob_url' => 'https://github.com/test/repo/blob/ghi789/README.md', + 'raw_url' => 'https://github.com/test/repo/raw/ghi789/README.md', + 'contents_url' => 'https://api.github.com/repos/test/repo/contents/README.md?ref=ghi789', + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $analysis = Github::pullRequests()->diff('test', 'repo', 1); + + expect($analysis['categories'])->toHaveKeys(['tests', 'config', 'docs', 'code', 'other']); + }); + }); + + describe('merge method with different MergeMethods', function () { + it('can merge with Merge method', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['merged' => true], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::pullRequests()->merge( + 'test', + 'repo', + 1, + 'Merge commit', + null, + MergeMethod::Merge, + ); + + expect($result)->toBeTrue(); + }); + + it('can merge with Squash method', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['merged' => true], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::pullRequests()->merge( + 'test', + 'repo', + 1, + 'Squash commit', + null, + MergeMethod::Squash, + ); + + expect($result)->toBeTrue(); + }); + + it('can merge with Rebase method', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['merged' => true], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::pullRequests()->merge( + 'test', + 'repo', + 1, + null, + null, + MergeMethod::Rebase, + ); + + expect($result)->toBeTrue(); + }); + + it('uses Merge as default method', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(['merged' => true], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::pullRequests()->merge('test', 'repo', 1); + + expect($result)->toBeTrue(); + }); + }); +}); diff --git a/tests/Resources/ReleasesResourceTest.php b/tests/Resources/ReleasesResourceTest.php new file mode 100644 index 0000000..fa5b542 --- /dev/null +++ b/tests/Resources/ReleasesResourceTest.php @@ -0,0 +1,281 @@ + 'fake-token']); + + $this->createReleaseMock = function (array $overrides = []) { + return array_merge([ + 'url' => 'https://api.github.com/repos/owner/repo/releases/1', + 'assets_url' => 'https://api.github.com/repos/owner/repo/releases/1/assets', + 'upload_url' => 'https://uploads.github.com/repos/owner/repo/releases/1/assets{?name,label}', + 'html_url' => 'https://github.com/owner/repo/releases/tag/v1.0.0', + 'id' => 1, + 'author' => $this->createMockUserData('releaser', 1), + 'node_id' => 'MDc6UmVsZWFzZTE=', + 'tag_name' => 'v1.0.0', + 'target_commitish' => 'master', + 'name' => 'v1.0.0', + 'draft' => false, + 'prerelease' => false, + 'created_at' => '2024-01-01T00:00:00Z', + 'published_at' => '2024-01-01T00:00:00Z', + 'assets' => [], + 'tarball_url' => 'https://api.github.com/repos/owner/repo/tarball/v1.0.0', + 'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v1.0.0', + 'body' => 'Release notes', + ], $overrides); + }; +}); + +describe('ReleasesResource', function () { + it('can access releases resource through Github facade', function () { + $resource = Github::releases(); + + expect($resource)->toBeInstanceOf(ReleasesResource::class); + }); + + describe('all method', function () { + it('returns array of ReleaseData', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + ($this->createReleaseMock)(['id' => 1, 'tag_name' => 'v1.0.0']), + ($this->createReleaseMock)(['id' => 2, 'tag_name' => 'v2.0.0']), + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $releases = Github::releases()->all('owner', 'repo'); + + expect($releases) + ->toBeArray() + ->toHaveCount(2) + ->and($releases[0])->toBeInstanceOf(ReleaseData::class) + ->and($releases[0]->tag_name)->toBe('v1.0.0') + ->and($releases[1]->tag_name)->toBe('v2.0.0'); + }); + + it('accepts pagination parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->createReleaseMock)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $releases = Github::releases()->all('owner', 'repo', per_page: 10, page: 2); + + expect($releases)->toBeArray(); + }); + + it('handles empty releases list', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $releases = Github::releases()->all('owner', 'repo'); + + expect($releases) + ->toBeArray() + ->toBeEmpty(); + }); + + it('accepts null pagination parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->createReleaseMock)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $releases = Github::releases()->all('owner', 'repo', per_page: null, page: null); + + expect($releases)->toBeArray(); + }); + }); + + describe('get method', function () { + it('returns ReleaseData for valid release ID', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)([ + 'id' => 123456, + 'tag_name' => 'v1.5.0', + 'name' => 'Version 1.5.0', + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->get('owner', 'repo', 123456); + + expect($release) + ->toBeInstanceOf(ReleaseData::class) + ->and($release->id)->toBe(123456) + ->and($release->tag_name)->toBe('v1.5.0'); + }); + + it('returns release with assets', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)([ + 'assets' => [ + [ + 'id' => 1, + 'name' => 'release.zip', + 'size' => 1024000, + 'download_count' => 42, + ], + ], + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->get('owner', 'repo', 1); + + expect($release->assets) + ->toBeArray() + ->toHaveCount(1) + ->and($release->assets[0]['name'])->toBe('release.zip'); + }); + + it('handles draft release', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)([ + 'draft' => true, + 'prerelease' => false, + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->get('owner', 'repo', 1); + + expect($release->draft)->toBeTrue() + ->and($release->prerelease)->toBeFalse(); + }); + + it('handles prerelease', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)([ + 'draft' => false, + 'prerelease' => true, + 'tag_name' => 'v1.0.0-beta', + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->get('owner', 'repo', 1); + + expect($release->prerelease)->toBeTrue() + ->and($release->draft)->toBeFalse() + ->and($release->tag_name)->toContain('beta'); + }); + }); + + describe('latest method', function () { + it('returns ReleaseData for latest release', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)([ + 'id' => 999, + 'tag_name' => 'v3.0.0', + 'name' => 'Latest Stable Release', + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->latest('owner', 'repo'); + + expect($release) + ->toBeInstanceOf(ReleaseData::class) + ->and($release->tag_name)->toBe('v3.0.0'); + }); + + it('returns non-prerelease release', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)([ + 'prerelease' => false, + 'draft' => false, + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->latest('owner', 'repo'); + + expect($release->prerelease)->toBeFalse() + ->and($release->draft)->toBeFalse(); + }); + }); + + describe('ReleaseData properties', function () { + it('has all required properties', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->get('owner', 'repo', 1); + + expect($release) + ->toHaveProperty('id') + ->toHaveProperty('tag_name') + ->toHaveProperty('name') + ->toHaveProperty('body') + ->toHaveProperty('draft') + ->toHaveProperty('prerelease') + ->toHaveProperty('created_at') + ->toHaveProperty('published_at') + ->toHaveProperty('assets') + ->toHaveProperty('author'); + }); + + it('handles null body', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make(($this->createReleaseMock)([ + 'body' => null, + ]), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $release = Github::releases()->get('owner', 'repo', 1); + + expect($release->body)->toBeNull(); + }); + }); + + describe('different repository formats', function () { + it('works with standard owner/repo format', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->createReleaseMock)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $releases = Github::releases()->all('jordanpartridge', 'github-client'); + + expect($releases)->toBeArray(); + }); + + it('works with organization repositories', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([($this->createReleaseMock)()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $releases = Github::releases()->all('laravel', 'framework'); + + expect($releases)->toBeArray(); + }); + }); +}); diff --git a/tests/Resources/RepoResourceTest.php b/tests/Resources/RepoResourceTest.php new file mode 100644 index 0000000..483beec --- /dev/null +++ b/tests/Resources/RepoResourceTest.php @@ -0,0 +1,339 @@ + 'fake-token']); +}); + +describe('RepoResource', function () { + it('can access repos resource through Github facade', function () { + $resource = Github::repos(); + + expect($resource)->toBeInstanceOf(RepoResource::class); + }); + + describe('all method', function () { + it('returns a Response object', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all(); + + expect($response->status())->toBe(200); + }); + + it('accepts all optional parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all( + per_page: 50, + page: 2, + visibility: Visibility::PUBLIC, + sort: Sort::CREATED, + direction: Direction::DESC, + type: Type::Owner, + ); + + expect($response->status())->toBe(200); + }); + + it('works with null parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all( + per_page: null, + page: null, + visibility: null, + sort: null, + direction: null, + type: null, + ); + + expect($response->status())->toBe(200); + }); + }); + + describe('allWithPagination method', function () { + it('returns array of RepoData', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $repos = Github::repos()->allWithPagination(); + + expect($repos)->toBeArray() + ->and($repos[0])->toBeInstanceOf(RepoData::class); + }); + + it('accepts all optional parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $repos = Github::repos()->allWithPagination( + per_page: 50, + visibility: Visibility::PRIVATE, + sort: Sort::UPDATED, + direction: Direction::ASC, + type: Type::Member, + ); + + expect($repos)->toBeArray(); + }); + + it('handles empty repository list', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $repos = Github::repos()->allWithPagination(); + + expect($repos) + ->toBeArray() + ->toBeEmpty(); + }); + }); + + describe('get method', function () { + it('returns RepoData for valid repository', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make($this->createMockRepoData('test-repo', 1, 'owner'), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $repo = Repo::fromFullName('owner/test-repo'); + $result = Github::repos()->get($repo); + + expect($result) + ->toBeInstanceOf(RepoData::class) + ->and($result->name)->toBe('test-repo'); + }); + + it('accepts Repo value object as parameter', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make($this->createMockRepoData(), 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $repo = Repo::fromFullName('jordanpartridge/github-client'); + $result = Github::repos()->get($repo); + + expect($result)->toBeInstanceOf(RepoData::class); + }); + }); + + describe('delete method', function () { + it('returns Response for delete operation', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 204), + ]); + + Github::connector()->withMockClient($mockClient); + + $repo = Repo::fromFullName('owner/test-repo'); + $response = Github::repos()->delete($repo); + + expect($response->status())->toBe(204); + }); + + it('accepts Repo value object as parameter', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([], 204), + ]); + + Github::connector()->withMockClient($mockClient); + + $repo = Repo::fromFullName('jordanpartridge/github-client'); + $response = Github::repos()->delete($repo); + + expect($response->status())->toBe(204); + }); + }); + + describe('search method', function () { + it('returns SearchRepositoriesData', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 1, + 'incomplete_results' => false, + 'items' => [$this->createMockRepoData()], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::repos()->search('laravel'); + + expect($result) + ->toBeInstanceOf(SearchRepositoriesData::class) + ->and($result->total_count)->toBe(1); + }); + + it('accepts all optional parameters', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 0, + 'incomplete_results' => false, + 'items' => [], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::repos()->search( + query: 'topic:conduit-component', + sort: 'stars', + order: Direction::DESC, + per_page: 20, + page: 1, + ); + + expect($result)->toBeInstanceOf(SearchRepositoriesData::class); + }); + + it('can search by topic', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 5, + 'incomplete_results' => false, + 'items' => [ + $this->createMockRepoData('repo-1'), + $this->createMockRepoData('repo-2'), + ], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::repos()->search('topic:laravel'); + + expect($result->total_count)->toBe(5) + ->and($result->items)->toHaveCount(2); + }); + + it('can search with sort parameter', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 0, + 'incomplete_results' => false, + 'items' => [], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::repos()->search('php', sort: 'stars'); + + expect($result)->toBeInstanceOf(SearchRepositoriesData::class); + }); + + it('handles empty search results', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([ + 'total_count' => 0, + 'incomplete_results' => false, + 'items' => [], + ], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $result = Github::repos()->search('nonexistent-query-12345'); + + expect($result->total_count)->toBe(0) + ->and($result->items)->toBeEmpty(); + }); + }); + + describe('type parameter', function () { + it('can filter by All type', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all(type: Type::All); + + expect($response->status())->toBe(200); + }); + + it('can filter by Public type', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all(type: Type::Public); + + expect($response->status())->toBe(200); + }); + + it('can filter by Private type', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all(type: Type::Private); + + expect($response->status())->toBe(200); + }); + + it('can filter by Forks type', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all(type: Type::Forks); + + expect($response->status())->toBe(200); + }); + + it('can filter by Sources type', function () { + $mockClient = new MockClient([ + '*' => MockResponse::make([$this->createMockRepoData()], 200), + ]); + + Github::connector()->withMockClient($mockClient); + + $response = Github::repos()->all(type: Type::Sources); + + expect($response->status())->toBe(200); + }); + }); +}); From f2a16620f9ee86588f88e2fe2b3f3eb991e78666 Mon Sep 17 00:00:00 2001 From: Agent Bot Date: Thu, 1 Jan 2026 22:34:56 +0000 Subject: [PATCH 7/7] test: add comprehensive test coverage for Auth and Exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 189 tests covering 11 files in src/Auth/ and src/Exceptions/: Auth tests: - TokenAuthentication: validation, headers, refresh behavior - TokenResolver: multi-source token resolution, env isolation - GithubOAuth: authorization URL generation, token exchange - GitHubAppAuthentication: JWT generation, installation tokens Exception tests: - GithubClientException: context management, inheritance - AuthenticationException: factory methods, error types - GithubAuthException: backward compatibility - ApiException: response parsing, error details - ValidationException: field/value tracking - RateLimitException: reset time calculations - NetworkException: timeout/connection errors Coverage achieved: - Auth/AuthenticationStrategy: 100% - Auth/GitHubAppAuthentication: 97.8% - Auth/GithubOAuth: 100% - Auth/TokenAuthentication: 100% - Auth/TokenResolver: 97.6% - All Exceptions: 100% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Unit/Auth/GitHubAppAuthenticationTest.php | 303 ++++++++++++++++ tests/Unit/Auth/GithubOAuthTest.php | 167 +++++++++ tests/Unit/Auth/TokenAuthenticationTest.php | 116 ++++++ tests/Unit/Auth/TokenResolverTest.php | 332 ++++++++++++++++++ tests/Unit/Exceptions/ApiExceptionTest.php | 261 ++++++++++++++ .../AuthenticationExceptionTest.php | 143 ++++++++ .../Exceptions/GithubAuthExceptionTest.php | 80 +++++ .../Exceptions/GithubClientExceptionTest.php | 134 +++++++ .../Unit/Exceptions/NetworkExceptionTest.php | 128 +++++++ .../Exceptions/RateLimitExceptionTest.php | 168 +++++++++ .../Exceptions/ValidationExceptionTest.php | 149 ++++++++ 11 files changed, 1981 insertions(+) create mode 100644 tests/Unit/Auth/GitHubAppAuthenticationTest.php create mode 100644 tests/Unit/Auth/GithubOAuthTest.php create mode 100644 tests/Unit/Auth/TokenAuthenticationTest.php create mode 100644 tests/Unit/Auth/TokenResolverTest.php create mode 100644 tests/Unit/Exceptions/ApiExceptionTest.php create mode 100644 tests/Unit/Exceptions/AuthenticationExceptionTest.php create mode 100644 tests/Unit/Exceptions/GithubAuthExceptionTest.php create mode 100644 tests/Unit/Exceptions/GithubClientExceptionTest.php create mode 100644 tests/Unit/Exceptions/NetworkExceptionTest.php create mode 100644 tests/Unit/Exceptions/RateLimitExceptionTest.php create mode 100644 tests/Unit/Exceptions/ValidationExceptionTest.php diff --git a/tests/Unit/Auth/GitHubAppAuthenticationTest.php b/tests/Unit/Auth/GitHubAppAuthenticationTest.php new file mode 100644 index 0000000..c40fc50 --- /dev/null +++ b/tests/Unit/Auth/GitHubAppAuthenticationTest.php @@ -0,0 +1,303 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + $key = openssl_pkey_new($config); + openssl_pkey_export($key, $privateKey); + + return $privateKey; +} + +describe('GitHubAppAuthentication', function () { + describe('validate', function () { + it('throws exception for empty app ID', function () { + $auth = new GitHubAppAuthentication( + appId: '', + privateKey: 'test-key', + ); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class, 'App ID is required'); + }); + + it('throws exception for non-numeric app ID', function () { + $auth = new GitHubAppAuthentication( + appId: 'not-a-number', + privateKey: 'test-key', + ); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class, 'App ID must be numeric'); + }); + + it('throws exception for empty private key', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: '', + ); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class, 'Private key is required'); + }); + + it('throws exception for invalid private key format', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: 'this-is-not-a-valid-pem-key', + ); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class, 'Invalid private key format'); + }); + + it('validates with valid PEM private key', function () { + $privateKey = generateTestPrivateKey(); + + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $privateKey, + ); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('validates with base64 encoded private key', function () { + $privateKey = generateTestPrivateKey(); + $base64Key = base64_encode($privateKey); + + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: $base64Key, + ); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + }); + + describe('getType', function () { + it('returns github_app type', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + ); + + expect($auth->getType())->toBe('github_app'); + }); + }); + + describe('getAuthorizationHeader', function () { + it('returns JWT bearer token', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + ); + + $header = $auth->getAuthorizationHeader(); + + expect($header)->toStartWith('Bearer '); + + // Extract and decode JWT + $token = substr($header, 7); + $parts = explode('.', $token); + expect($parts)->toHaveCount(3); // JWT has 3 parts: header.payload.signature + + // Decode payload + $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); + expect($payload['iss'])->toBe('12345') + ->and($payload)->toHaveKey('iat') + ->and($payload)->toHaveKey('exp') + ->and($payload['exp'] - $payload['iat'])->toBe(600); // 10 minutes + }); + + it('returns installation token when set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + // Set a valid installation token that expires in the future + $expiry = new DateTimeImmutable('+1 hour'); + $auth->setInstallationToken('ghs_installation_token_12345', $expiry); + + $header = $auth->getAuthorizationHeader(); + + expect($header)->toBe('Bearer ghs_installation_token_12345'); + }); + + it('returns JWT when installation token is expired', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + // Set an expired installation token (expired 1 hour ago) + $expiry = new DateTimeImmutable('-1 hour'); + $auth->setInstallationToken('ghs_expired_token', $expiry); + + $header = $auth->getAuthorizationHeader(); + + // Should return JWT, not the expired installation token + expect($header)->toStartWith('Bearer ') + ->and($header)->not->toContain('ghs_expired_token'); + }); + + it('returns JWT when installation token is about to expire within buffer', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + // Set installation token that expires in 4 minutes (within 5-minute buffer) + $expiry = new DateTimeImmutable('+4 minutes'); + $auth->setInstallationToken('ghs_soon_expired_token', $expiry); + + $header = $auth->getAuthorizationHeader(); + + // Should return JWT since token is about to expire + expect($header)->toStartWith('Bearer ') + ->and($header)->not->toContain('ghs_soon_expired_token'); + }); + }); + + describe('needsRefresh', function () { + it('returns false when no installation ID is set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + ); + + expect($auth->needsRefresh())->toBeFalse(); + }); + + it('returns true when installation ID is set but no token', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + expect($auth->needsRefresh())->toBeTrue(); + }); + + it('returns false when valid installation token is set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + $expiry = new DateTimeImmutable('+1 hour'); + $auth->setInstallationToken('ghs_valid_token', $expiry); + + expect($auth->needsRefresh())->toBeFalse(); + }); + + it('returns true when installation token is expired', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + $expiry = new DateTimeImmutable('-1 hour'); + $auth->setInstallationToken('ghs_expired_token', $expiry); + + expect($auth->needsRefresh())->toBeTrue(); + }); + }); + + describe('refresh', function () { + it('does nothing when no installation ID is set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + ); + + // Should not throw + $auth->refresh(); + + expect(true)->toBeTrue(); + }); + + it('throws exception when installation ID is set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + expect(fn () => $auth->refresh()) + ->toThrow(AuthenticationException::class, 'Installation token refresh not yet implemented'); + }); + }); + + describe('setInstallationToken', function () { + it('sets the installation token and expiry', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + $expiry = new DateTimeImmutable('+1 hour'); + $auth->setInstallationToken('ghs_my_token', $expiry); + + $header = $auth->getAuthorizationHeader(); + expect($header)->toBe('Bearer ghs_my_token'); + }); + }); + + describe('getAppId', function () { + it('returns the app ID', function () { + $auth = new GitHubAppAuthentication( + appId: '98765', + privateKey: generateTestPrivateKey(), + ); + + expect($auth->getAppId())->toBe('98765'); + }); + }); + + describe('getInstallationId', function () { + it('returns null when no installation ID is set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + ); + + expect($auth->getInstallationId())->toBeNull(); + }); + + it('returns the installation ID when set', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + installationId: '67890', + ); + + expect($auth->getInstallationId())->toBe('67890'); + }); + }); + + describe('implements AuthenticationStrategy', function () { + it('implements the interface', function () { + $auth = new GitHubAppAuthentication( + appId: '12345', + privateKey: generateTestPrivateKey(), + ); + + expect($auth)->toBeInstanceOf(\JordanPartridge\GithubClient\Auth\AuthenticationStrategy::class); + }); + }); +}); diff --git a/tests/Unit/Auth/GithubOAuthTest.php b/tests/Unit/Auth/GithubOAuthTest.php new file mode 100644 index 0000000..809e2c5 --- /dev/null +++ b/tests/Unit/Auth/GithubOAuthTest.php @@ -0,0 +1,167 @@ +getAuthorizationUrl(); + + expect($url)->toStartWith('https://github.com/login/oauth/authorize?') + ->and($url)->toContain('client_id=test_client_id') + ->and($url)->toContain('redirect_uri=' . urlencode('https://example.com/callback')) + ->and($url)->toContain('scope=repo') + ->and($url)->toContain('state='); + }); + + it('generates authorization URL with custom scopes', function () { + $oauth = new GithubOAuth( + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + redirectUrl: 'https://example.com/callback', + ); + + $url = $oauth->getAuthorizationUrl(['repo', 'user', 'read:org']); + + expect($url)->toContain('scope=' . urlencode('repo user read:org')); + }); + + it('generates unique state for each call', function () { + $oauth = new GithubOAuth( + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + redirectUrl: 'https://example.com/callback', + ); + + $url1 = $oauth->getAuthorizationUrl(); + $url2 = $oauth->getAuthorizationUrl(); + + // Extract state values + preg_match('/state=([^&]+)/', $url1, $matches1); + preg_match('/state=([^&]+)/', $url2, $matches2); + + expect($matches1[1])->not->toBe($matches2[1]); + }); + + it('generates 32-character hex state', function () { + $oauth = new GithubOAuth( + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + redirectUrl: 'https://example.com/callback', + ); + + $url = $oauth->getAuthorizationUrl(); + + preg_match('/state=([^&]+)/', $url, $matches); + $state = $matches[1]; + + expect(strlen($state))->toBe(32) + ->and(ctype_xdigit($state))->toBeTrue(); + }); + }); + + describe('getAccessToken', function () { + it('exchanges code for access token', function () { + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response( + 'access_token=gho_test_access_token&token_type=bearer&scope=repo', + 200, + ), + ]); + + $oauth = new GithubOAuth( + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + redirectUrl: 'https://example.com/callback', + ); + + $token = $oauth->getAccessToken('test_auth_code'); + + expect($token)->toBe('gho_test_access_token'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://github.com/login/oauth/access_token' + && $request['client_id'] === 'test_client_id' + && $request['client_secret'] === 'test_client_secret' + && $request['code'] === 'test_auth_code' + && $request['redirect_uri'] === 'https://example.com/callback'; + }); + }); + + it('throws exception when access token is not in response', function () { + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response( + 'error=bad_verification_code&error_description=The+code+passed+is+incorrect+or+expired.', + 200, + ), + ]); + + $oauth = new GithubOAuth( + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + redirectUrl: 'https://example.com/callback', + ); + + expect(fn () => $oauth->getAccessToken('invalid_code')) + ->toThrow(AuthenticationException::class, 'Failed to get access token'); + }); + + it('throws exception on HTTP error', function () { + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response( + ['message' => 'Server error'], + 500, + ), + ]); + + $oauth = new GithubOAuth( + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + redirectUrl: 'https://example.com/callback', + ); + + expect(fn () => $oauth->getAccessToken('test_code')) + ->toThrow(\Illuminate\Http\Client\RequestException::class); + }); + + it('handles JSON response format', function () { + // GitHub can also return JSON format depending on Accept header + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response( + 'access_token=gho_json_token&token_type=bearer', + 200, + ), + ]); + + $oauth = new GithubOAuth( + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + redirectUrl: 'https://example.com/callback', + ); + + $token = $oauth->getAccessToken('test_code'); + + expect($token)->toBe('gho_json_token'); + }); + }); + + describe('constructor', function () { + it('accepts required parameters', function () { + $oauth = new GithubOAuth( + clientId: 'my_client_id', + clientSecret: 'my_client_secret', + redirectUrl: 'https://myapp.com/auth/callback', + ); + + expect($oauth)->toBeInstanceOf(GithubOAuth::class); + }); + }); +}); diff --git a/tests/Unit/Auth/TokenAuthenticationTest.php b/tests/Unit/Auth/TokenAuthenticationTest.php new file mode 100644 index 0000000..9102a00 --- /dev/null +++ b/tests/Unit/Auth/TokenAuthenticationTest.php @@ -0,0 +1,116 @@ +getAuthorizationHeader())->toBe('Bearer ghp_test123456789012345678901234567890ab'); + }); + }); + + describe('validate', function () { + it('throws exception for empty token', function () { + $auth = new TokenAuthentication(''); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class); + }); + + it('throws exception for token that is too short', function () { + $auth = new TokenAuthentication('short'); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class, 'Token appears to be too short'); + }); + + it('throws exception for token with invalid prefix', function () { + $auth = new TokenAuthentication('invalid_prefix_token_12345'); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class, 'Token format appears invalid'); + }); + + it('validates personal access token with ghp_ prefix', function () { + $auth = new TokenAuthentication('ghp_test123456789012345678901234567890ab'); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('validates OAuth token with gho_ prefix', function () { + $auth = new TokenAuthentication('gho_test123456789012345678901234567890ab'); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('validates user-to-server token with ghu_ prefix', function () { + $auth = new TokenAuthentication('ghu_test123456789012345678901234567890ab'); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('validates server-to-server token with ghs_ prefix', function () { + $auth = new TokenAuthentication('ghs_test123456789012345678901234567890ab'); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('validates refresh token with ghr_ prefix', function () { + $auth = new TokenAuthentication('ghr_test123456789012345678901234567890ab'); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('validates legacy 40-character alphanumeric token', function () { + $auth = new TokenAuthentication('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'); + + expect(fn () => $auth->validate())->not->toThrow(AuthenticationException::class); + }); + + it('rejects invalid legacy token format', function () { + // Token with uppercase letters (not valid hex) + $auth = new TokenAuthentication('A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2'); + + expect(fn () => $auth->validate()) + ->toThrow(AuthenticationException::class, 'Token format appears invalid'); + }); + }); + + describe('getType', function () { + it('returns token type', function () { + $auth = new TokenAuthentication('ghp_test123456789012345678901234567890ab'); + + expect($auth->getType())->toBe('token'); + }); + }); + + describe('needsRefresh', function () { + it('returns false for personal access tokens', function () { + $auth = new TokenAuthentication('ghp_test123456789012345678901234567890ab'); + + expect($auth->needsRefresh())->toBeFalse(); + }); + }); + + describe('refresh', function () { + it('does nothing for personal access tokens', function () { + $auth = new TokenAuthentication('ghp_test123456789012345678901234567890ab'); + + // Should not throw any exception + $auth->refresh(); + + expect($auth->getAuthorizationHeader())->toBe('Bearer ghp_test123456789012345678901234567890ab'); + }); + }); + + describe('implements AuthenticationStrategy', function () { + it('implements the interface', function () { + $auth = new TokenAuthentication('ghp_test123456789012345678901234567890ab'); + + expect($auth)->toBeInstanceOf(\JordanPartridge\GithubClient\Auth\AuthenticationStrategy::class); + }); + }); +}); diff --git a/tests/Unit/Auth/TokenResolverTest.php b/tests/Unit/Auth/TokenResolverTest.php new file mode 100644 index 0000000..97be7f1 --- /dev/null +++ b/tests/Unit/Auth/TokenResolverTest.php @@ -0,0 +1,332 @@ +originalGithubToken = getenv('GITHUB_TOKEN'); + $this->originalGhToken = getenv('GH_TOKEN'); + + // Clear environment variables completely (using false to unset) + putenv('GITHUB_TOKEN'); + putenv('GH_TOKEN'); + + // Also clear from $_ENV and $_SERVER which Laravel's env() checks + unset($_ENV['GITHUB_TOKEN'], $_ENV['GH_TOKEN']); + unset($_SERVER['GITHUB_TOKEN'], $_SERVER['GH_TOKEN']); + + // Reset the static lastSource property + $reflection = new ReflectionClass(TokenResolver::class); + $property = $reflection->getProperty('lastSource'); + $property->setAccessible(true); + $property->setValue(null, null); + }); + + afterEach(function () { + // Restore original values + if ($this->originalGithubToken !== false) { + putenv("GITHUB_TOKEN={$this->originalGithubToken}"); + } + if ($this->originalGhToken !== false) { + putenv("GH_TOKEN={$this->originalGhToken}"); + } + }); + + describe('resolve', function () { + it('returns null when no token is available', function () { + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI not available + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBeNull(); + }); + + it('returns GITHUB_TOKEN from environment when set', function () { + putenv('GITHUB_TOKEN=ghp_env_token_12345'); + $_ENV['GITHUB_TOKEN'] = 'ghp_env_token_12345'; + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI not available + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBe('ghp_env_token_12345'); + }); + + it('returns GH_TOKEN from environment when GITHUB_TOKEN is not set', function () { + putenv('GH_TOKEN=ghp_gh_token_12345'); + $_ENV['GH_TOKEN'] = 'ghp_gh_token_12345'; + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI not available + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBe('ghp_gh_token_12345'); + }); + + it('returns config token when env vars are not set', function () { + config()->set('github-client.token', 'ghp_config_token_12345'); + + // Mock Process to simulate gh CLI not available + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBe('ghp_config_token_12345'); + }); + + it('ignores placeholder token in config', function () { + config()->set('github-client.token', 'your-github-token-here'); + + // Mock Process to simulate gh CLI not available + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBeNull(); + }); + + it('prefers GitHub CLI token over environment variables', function () { + putenv('GITHUB_TOKEN=ghp_env_token'); + $_ENV['GITHUB_TOKEN'] = 'ghp_env_token'; + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI available and authenticated + Process::fake([ + 'which gh' => Process::result(output: '/usr/bin/gh', exitCode: 0), + 'gh auth token' => Process::result(output: 'ghp_cli_token_12345', exitCode: 0), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBe('ghp_cli_token_12345'); + }); + + it('handles gh CLI not authenticated', function () { + putenv('GITHUB_TOKEN=ghp_env_fallback'); + $_ENV['GITHUB_TOKEN'] = 'ghp_env_fallback'; + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI available but not authenticated + Process::fake([ + 'which gh' => Process::result(output: '/usr/bin/gh', exitCode: 0), + 'gh auth token' => Process::result(output: '', exitCode: 1), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBe('ghp_env_fallback'); + }); + + it('handles gh auth token returning empty output', function () { + putenv('GITHUB_TOKEN=ghp_fallback'); + $_ENV['GITHUB_TOKEN'] = 'ghp_fallback'; + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI returning whitespace only + Process::fake([ + 'which gh' => Process::result(output: '/usr/bin/gh', exitCode: 0), + 'gh auth token' => Process::result(output: ' ', exitCode: 0), + ]); + + $token = TokenResolver::resolve(); + + expect($token)->toBe('ghp_fallback'); + }); + }); + + describe('hasAuthentication', function () { + it('returns true when token is available', function () { + putenv('GITHUB_TOKEN=ghp_test_token'); + $_ENV['GITHUB_TOKEN'] = 'ghp_test_token'; + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI not available + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + expect(TokenResolver::hasAuthentication())->toBeTrue(); + }); + + it('returns false when no token is available', function () { + config()->set('github-client.token', null); + + // Mock Process to simulate gh CLI not available + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + expect(TokenResolver::hasAuthentication())->toBeFalse(); + }); + }); + + describe('getAuthenticationStatus', function () { + it('returns status for GitHub CLI authentication', function () { + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '/usr/bin/gh', exitCode: 0), + 'gh auth token' => Process::result(output: 'ghp_cli_token', exitCode: 0), + ]); + + $status = TokenResolver::getAuthenticationStatus(); + + expect($status)->toBe('Authenticated via GitHub CLI'); + }); + + it('returns status for GITHUB_TOKEN environment variable', function () { + putenv('GITHUB_TOKEN=ghp_env_token'); + $_ENV['GITHUB_TOKEN'] = 'ghp_env_token'; + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $status = TokenResolver::getAuthenticationStatus(); + + expect($status)->toBe('Authenticated via environment variable (GITHUB_TOKEN)'); + }); + + it('returns status for GH_TOKEN environment variable', function () { + putenv('GH_TOKEN=ghp_gh_token'); + $_ENV['GH_TOKEN'] = 'ghp_gh_token'; + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $status = TokenResolver::getAuthenticationStatus(); + + expect($status)->toBe('Authenticated via environment variable (GH_TOKEN)'); + }); + + it('returns status for config file authentication', function () { + config()->set('github-client.token', 'ghp_config_token'); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $status = TokenResolver::getAuthenticationStatus(); + + expect($status)->toBe('Authenticated via config file'); + }); + + it('returns no authentication status when no token available', function () { + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + $status = TokenResolver::getAuthenticationStatus(); + + expect($status)->toBe('No authentication (public access only)'); + }); + }); + + describe('getAuthenticationHelp', function () { + it('returns helpful authentication guidance', function () { + $help = TokenResolver::getAuthenticationHelp(); + + expect($help)->toBeString() + ->and($help)->toContain('GitHub CLI') + ->and($help)->toContain('gh auth login') + ->and($help)->toContain('GITHUB_TOKEN') + ->and($help)->toContain('config/github-client.php') + ->and($help)->toContain('rate limits'); + }); + }); + + describe('getLastSource', function () { + it('returns null before any resolution', function () { + expect(TokenResolver::getLastSource())->toBeNull(); + }); + + it('returns GitHub CLI as source', function () { + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '/usr/bin/gh', exitCode: 0), + 'gh auth token' => Process::result(output: 'ghp_cli_token', exitCode: 0), + ]); + + TokenResolver::resolve(); + + expect(TokenResolver::getLastSource())->toBe('GitHub CLI'); + }); + + it('returns GITHUB_TOKEN as source', function () { + putenv('GITHUB_TOKEN=ghp_env_token'); + $_ENV['GITHUB_TOKEN'] = 'ghp_env_token'; + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + TokenResolver::resolve(); + + expect(TokenResolver::getLastSource())->toBe('GITHUB_TOKEN'); + }); + + it('returns GH_TOKEN as source', function () { + putenv('GH_TOKEN=ghp_gh_token'); + $_ENV['GH_TOKEN'] = 'ghp_gh_token'; + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + TokenResolver::resolve(); + + expect(TokenResolver::getLastSource())->toBe('GH_TOKEN'); + }); + + it('returns config as source', function () { + config()->set('github-client.token', 'ghp_config_token'); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + TokenResolver::resolve(); + + expect(TokenResolver::getLastSource())->toBe('config'); + }); + + it('returns null as source when no token found', function () { + config()->set('github-client.token', null); + + Process::fake([ + 'which gh' => Process::result(output: '', exitCode: 1), + ]); + + TokenResolver::resolve(); + + expect(TokenResolver::getLastSource())->toBeNull(); + }); + }); +}); diff --git a/tests/Unit/Exceptions/ApiExceptionTest.php b/tests/Unit/Exceptions/ApiExceptionTest.php new file mode 100644 index 0000000..5f8006e --- /dev/null +++ b/tests/Unit/Exceptions/ApiExceptionTest.php @@ -0,0 +1,261 @@ +createMockResponse = function (array $body, int $status = 400): Response { + $mockResponse = Mockery::mock(Response::class); + $mockResponse->shouldReceive('status')->andReturn($status); + $mockResponse->shouldReceive('json')->andReturn($body); + $mockResponse->shouldReceive('body')->andReturn(json_encode($body)); + + return $mockResponse; + }; + }); + + afterEach(function () { + Mockery::close(); + }); + + describe('constructor', function () { + it('sets response', function () { + $response = ($this->createMockResponse)(['message' => 'Bad Request'], 400); + $exception = new ApiException($response); + + expect($exception->getResponse())->toBe($response); + }); + + it('parses error details from response body', function () { + $response = ($this->createMockResponse)([ + 'message' => 'Validation Failed', + 'documentation_url' => 'https://docs.github.com/rest', + 'errors' => [ + ['resource' => 'Issue', 'code' => 'missing', 'field' => 'title'], + ], + ], 422); + $exception = new ApiException($response); + + $details = $exception->getErrorDetails(); + expect($details['message'])->toBe('Validation Failed') + ->and($details['documentation_url'])->toBe('https://docs.github.com/rest') + ->and($details['errors'])->toHaveCount(1); + }); + + it('uses status code as exception code', function () { + $response = ($this->createMockResponse)(['message' => 'Not Found'], 404); + $exception = new ApiException($response); + + expect($exception->getCode())->toBe(404); + }); + + it('generates message from response when not provided', function () { + $response = ($this->createMockResponse)(['message' => 'Not Found'], 404); + $exception = new ApiException($response); + + expect($exception->getMessage())->toBe('GitHub API error (404): Not Found'); + }); + + it('uses provided message when given', function () { + $response = ($this->createMockResponse)(['message' => 'Not Found'], 404); + $exception = new ApiException($response, 'Custom error message'); + + expect($exception->getMessage())->toBe('Custom error message'); + }); + + it('handles response without message field', function () { + $response = ($this->createMockResponse)(['error' => 'Unknown'], 500); + $exception = new ApiException($response); + + $details = $exception->getErrorDetails(); + expect($details['message'])->toBe('Unknown API error'); + }); + + it('handles response without documentation_url', function () { + $response = ($this->createMockResponse)(['message' => 'Error'], 500); + $exception = new ApiException($response); + + $details = $exception->getErrorDetails(); + expect($details['documentation_url'])->toBeNull(); + }); + + it('handles response without errors array', function () { + $response = ($this->createMockResponse)(['message' => 'Error'], 500); + $exception = new ApiException($response); + + $details = $exception->getErrorDetails(); + expect($details['errors'])->toBe([]); + }); + + it('includes response details in context', function () { + $response = ($this->createMockResponse)(['message' => 'Bad Request'], 400); + $exception = new ApiException($response); + + $context = $exception->getContext(); + expect($context)->toHaveKey('status_code') + ->and($context)->toHaveKey('response_body') + ->and($context)->toHaveKey('error_details') + ->and($context['status_code'])->toBe(400); + }); + + it('accepts previous exception', function () { + $previous = new Exception('Original'); + $response = ($this->createMockResponse)(['message' => 'Error'], 500); + $exception = new ApiException($response, '', $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + }); + + describe('getResponse', function () { + it('returns the original response', function () { + $response = ($this->createMockResponse)(['message' => 'Error'], 500); + $exception = new ApiException($response); + + expect($exception->getResponse())->toBe($response); + }); + }); + + describe('getErrorDetails', function () { + it('returns parsed error details', function () { + $response = ($this->createMockResponse)([ + 'message' => 'Problems parsing JSON', + 'documentation_url' => 'https://docs.github.com/rest/overview/resources-in-the-rest-api#client-errors', + ], 400); + $exception = new ApiException($response); + + $details = $exception->getErrorDetails(); + expect($details)->toHaveKey('message') + ->and($details)->toHaveKey('documentation_url') + ->and($details)->toHaveKey('errors'); + }); + }); + + describe('notFound', function () { + it('creates not found exception with resource name', function () { + $response = ($this->createMockResponse)(['message' => 'Not Found'], 404); + $exception = ApiException::notFound('repository owner/repo', $response); + + expect($exception->getMessage())->toBe('Resource not found: repository owner/repo') + ->and($exception->getCode())->toBe(404); + }); + + it('preserves response in not found exception', function () { + $response = ($this->createMockResponse)(['message' => 'Not Found'], 404); + $exception = ApiException::notFound('issue #123', $response); + + expect($exception->getResponse())->toBe($response); + }); + }); + + describe('forbidden', function () { + it('creates forbidden exception without reason', function () { + $response = ($this->createMockResponse)(['message' => 'Forbidden'], 403); + $exception = ApiException::forbidden($response); + + expect($exception->getMessage())->toBe('Access forbidden') + ->and($exception->getCode())->toBe(403); + }); + + it('creates forbidden exception with reason', function () { + $response = ($this->createMockResponse)(['message' => 'Forbidden'], 403); + $exception = ApiException::forbidden($response, 'Rate limit exceeded'); + + expect($exception->getMessage())->toBe('Access forbidden: Rate limit exceeded'); + }); + + it('preserves response in forbidden exception', function () { + $response = ($this->createMockResponse)(['message' => 'Forbidden'], 403); + $exception = ApiException::forbidden($response); + + expect($exception->getResponse())->toBe($response); + }); + }); + + describe('common API error scenarios', function () { + it('handles 401 Unauthorized', function () { + $response = ($this->createMockResponse)([ + 'message' => 'Requires authentication', + 'documentation_url' => 'https://docs.github.com/rest/overview/resources-in-the-rest-api#authentication', + ], 401); + $exception = new ApiException($response); + + expect($exception->getCode())->toBe(401) + ->and($exception->getMessage())->toContain('Requires authentication'); + }); + + it('handles 403 Forbidden with rate limit', function () { + $response = ($this->createMockResponse)([ + 'message' => 'API rate limit exceeded', + 'documentation_url' => 'https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting', + ], 403); + $exception = new ApiException($response); + + expect($exception->getCode())->toBe(403) + ->and($exception->getMessage())->toContain('rate limit'); + }); + + it('handles 422 Unprocessable Entity', function () { + $response = ($this->createMockResponse)([ + 'message' => 'Validation Failed', + 'errors' => [ + ['resource' => 'Issue', 'code' => 'missing_field', 'field' => 'title'], + ], + ], 422); + $exception = new ApiException($response); + + expect($exception->getCode())->toBe(422) + ->and($exception->getErrorDetails()['errors'])->toHaveCount(1); + }); + + it('handles 500 Internal Server Error', function () { + $response = ($this->createMockResponse)([ + 'message' => 'Server Error', + ], 500); + $exception = new ApiException($response); + + expect($exception->getCode())->toBe(500); + }); + + it('handles 502 Bad Gateway', function () { + $response = ($this->createMockResponse)([ + 'message' => 'Bad Gateway', + ], 502); + $exception = new ApiException($response); + + expect($exception->getCode())->toBe(502); + }); + + it('handles 503 Service Unavailable', function () { + $response = ($this->createMockResponse)([ + 'message' => 'Service Unavailable', + ], 503); + $exception = new ApiException($response); + + expect($exception->getCode())->toBe(503); + }); + }); + + describe('inheritance', function () { + it('extends GithubClientException', function () { + $response = ($this->createMockResponse)(['message' => 'Error'], 500); + $exception = new ApiException($response); + + expect($exception)->toBeInstanceOf(GithubClientException::class); + }); + + it('inherits addContext functionality', function () { + $response = ($this->createMockResponse)(['message' => 'Error'], 500); + $exception = new ApiException($response); + $exception->addContext('request_id', 'abc123'); + + $context = $exception->getContext(); + expect($context)->toHaveKey('request_id') + ->and($context['request_id'])->toBe('abc123'); + }); + }); +}); diff --git a/tests/Unit/Exceptions/AuthenticationExceptionTest.php b/tests/Unit/Exceptions/AuthenticationExceptionTest.php new file mode 100644 index 0000000..7d1a140 --- /dev/null +++ b/tests/Unit/Exceptions/AuthenticationExceptionTest.php @@ -0,0 +1,143 @@ +getMessage())->toBe('Authentication failed'); + }); + + it('defaults to token authentication type', function () { + $exception = new AuthenticationException('Test'); + + expect($exception->getAuthenticationType())->toBe('token'); + }); + + it('accepts custom authentication type', function () { + $exception = new AuthenticationException('Test', 'oauth'); + + expect($exception->getAuthenticationType())->toBe('oauth'); + }); + + it('defaults to 401 status code', function () { + $exception = new AuthenticationException('Test'); + + expect($exception->getCode())->toBe(401); + }); + + it('accepts custom status code', function () { + $exception = new AuthenticationException('Test', 'token', 403); + + expect($exception->getCode())->toBe(403); + }); + + it('accepts previous exception', function () { + $previous = new Exception('Original error'); + $exception = new AuthenticationException('Test', 'token', 401, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('includes authentication type in context', function () { + $exception = new AuthenticationException('Test', 'github_app'); + + $context = $exception->getContext(); + expect($context)->toHaveKey('authentication_type') + ->and($context['authentication_type'])->toBe('github_app'); + }); + }); + + describe('getAuthenticationType', function () { + it('returns the authentication type', function () { + $exception = new AuthenticationException('Test', 'bearer'); + + expect($exception->getAuthenticationType())->toBe('bearer'); + }); + }); + + describe('invalidToken', function () { + it('creates exception with default message', function () { + $exception = AuthenticationException::invalidToken(); + + expect($exception->getMessage())->toBe('Invalid or expired GitHub token') + ->and($exception->getAuthenticationType())->toBe('token') + ->and($exception->getCode())->toBe(401); + }); + + it('creates exception with custom message', function () { + $exception = AuthenticationException::invalidToken('Token has been revoked'); + + expect($exception->getMessage())->toBe('Token has been revoked'); + }); + }); + + describe('missingToken', function () { + it('creates exception with default message', function () { + $exception = AuthenticationException::missingToken(); + + expect($exception->getMessage())->toBe('GitHub token is required but not provided') + ->and($exception->getAuthenticationType())->toBe('token') + ->and($exception->getCode())->toBe(400); + }); + + it('creates exception with custom message', function () { + $exception = AuthenticationException::missingToken('Please provide a token'); + + expect($exception->getMessage())->toBe('Please provide a token'); + }); + }); + + describe('githubAppAuthFailed', function () { + it('creates exception with default message', function () { + $exception = AuthenticationException::githubAppAuthFailed(); + + expect($exception->getMessage())->toBe('GitHub App authentication failed') + ->and($exception->getAuthenticationType())->toBe('github_app') + ->and($exception->getCode())->toBe(401); + }); + + it('creates exception with custom message', function () { + $exception = AuthenticationException::githubAppAuthFailed('Invalid private key'); + + expect($exception->getMessage())->toBe('Invalid private key'); + }); + }); + + describe('noTokenFound', function () { + it('creates exception with default guidance', function () { + $exception = AuthenticationException::noTokenFound(); + + expect($exception->getMessage())->toBe('Authentication required: No GitHub token found') + ->and($exception->getAuthenticationType())->toBe('token') + ->and($exception->getCode())->toBe(400); + }); + + it('creates exception with custom guidance', function () { + $exception = AuthenticationException::noTokenFound('Set GITHUB_TOKEN environment variable'); + + expect($exception->getMessage())->toBe('Authentication required: Set GITHUB_TOKEN environment variable'); + }); + }); + + describe('inheritance', function () { + it('extends GithubClientException', function () { + $exception = new AuthenticationException('Test'); + + expect($exception)->toBeInstanceOf(GithubClientException::class); + }); + + it('inherits addContext functionality', function () { + $exception = new AuthenticationException('Test'); + $exception->addContext('attempt', 3); + + $context = $exception->getContext(); + expect($context)->toHaveKey('authentication_type') + ->and($context)->toHaveKey('attempt') + ->and($context['attempt'])->toBe(3); + }); + }); +}); diff --git a/tests/Unit/Exceptions/GithubAuthExceptionTest.php b/tests/Unit/Exceptions/GithubAuthExceptionTest.php new file mode 100644 index 0000000..05b3ca4 --- /dev/null +++ b/tests/Unit/Exceptions/GithubAuthExceptionTest.php @@ -0,0 +1,80 @@ +toBeInstanceOf(AuthenticationException::class); + }); + + it('inherits all AuthenticationException functionality', function () { + $exception = new GithubAuthException('Auth failed', 'oauth', 403); + + expect($exception->getMessage())->toBe('Auth failed') + ->and($exception->getAuthenticationType())->toBe('oauth') + ->and($exception->getCode())->toBe(403); + }); + + it('has access to static factory methods from parent', function () { + // GithubAuthException should work just like AuthenticationException + $exception = new GithubAuthException('Token invalid'); + $exception->addContext('source', 'legacy'); + + $context = $exception->getContext(); + expect($context)->toHaveKey('authentication_type') + ->and($context)->toHaveKey('source'); + }); + }); + + describe('inheritance chain', function () { + it('is instance of GithubClientException', function () { + $exception = new GithubAuthException('Test'); + + expect($exception)->toBeInstanceOf(GithubClientException::class); + }); + + it('is instance of Exception', function () { + $exception = new GithubAuthException('Test'); + + expect($exception)->toBeInstanceOf(Exception::class); + }); + + it('is instance of Throwable', function () { + $exception = new GithubAuthException('Test'); + + expect($exception)->toBeInstanceOf(Throwable::class); + }); + }); + + describe('backward compatibility', function () { + it('can be caught as AuthenticationException', function () { + $caught = false; + + try { + throw new GithubAuthException('Legacy error'); + } catch (AuthenticationException $e) { + $caught = true; + expect($e->getMessage())->toBe('Legacy error'); + } + + expect($caught)->toBeTrue(); + }); + + it('can be caught as GithubClientException', function () { + $caught = false; + + try { + throw new GithubAuthException('Legacy error'); + } catch (GithubClientException $e) { + $caught = true; + } + + expect($caught)->toBeTrue(); + }); + }); +}); diff --git a/tests/Unit/Exceptions/GithubClientExceptionTest.php b/tests/Unit/Exceptions/GithubClientExceptionTest.php new file mode 100644 index 0000000..cfd17cb --- /dev/null +++ b/tests/Unit/Exceptions/GithubClientExceptionTest.php @@ -0,0 +1,134 @@ +getMessage())->toBe('Test error message'); + }); + + it('sets code correctly', function () { + $exception = createTestException('Test', 500); + + expect($exception->getCode())->toBe(500); + }); + + it('sets previous exception correctly', function () { + $previous = new Exception('Previous error'); + $exception = createTestException('Test', 0, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('sets context correctly', function () { + $context = ['key1' => 'value1', 'key2' => 'value2']; + $exception = createTestException('Test', 0, null, $context); + + expect($exception->getContext())->toBe($context); + }); + + it('defaults to empty context', function () { + $exception = createTestException('Test'); + + expect($exception->getContext())->toBe([]); + }); + }); + + describe('getContext', function () { + it('returns the context array', function () { + $context = ['request_id' => '12345', 'endpoint' => '/repos']; + $exception = createTestException('Test', 0, null, $context); + + expect($exception->getContext())->toBe($context); + }); + }); + + describe('addContext', function () { + it('adds a single context value', function () { + $exception = createTestException('Test'); + $exception->addContext('key', 'value'); + + expect($exception->getContext())->toBe(['key' => 'value']); + }); + + it('adds multiple context values', function () { + $exception = createTestException('Test'); + $exception->addContext('key1', 'value1'); + $exception->addContext('key2', 'value2'); + + expect($exception->getContext())->toBe([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); + }); + + it('overwrites existing context key', function () { + $exception = createTestException('Test', 0, null, ['key' => 'original']); + $exception->addContext('key', 'updated'); + + expect($exception->getContext()['key'])->toBe('updated'); + }); + + it('returns $this for method chaining', function () { + $exception = createTestException('Test'); + $result = $exception->addContext('key', 'value'); + + expect($result)->toBe($exception); + }); + + it('allows chaining multiple addContext calls', function () { + $exception = createTestException('Test'); + $exception + ->addContext('key1', 'value1') + ->addContext('key2', 'value2') + ->addContext('key3', 'value3'); + + expect($exception->getContext())->toBe([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]); + }); + + it('accepts various value types', function () { + $exception = createTestException('Test'); + $exception->addContext('string', 'text'); + $exception->addContext('int', 42); + $exception->addContext('float', 3.14); + $exception->addContext('bool', true); + $exception->addContext('array', ['a', 'b']); + $exception->addContext('null', null); + + $context = $exception->getContext(); + expect($context['string'])->toBe('text') + ->and($context['int'])->toBe(42) + ->and($context['float'])->toBe(3.14) + ->and($context['bool'])->toBeTrue() + ->and($context['array'])->toBe(['a', 'b']) + ->and($context['null'])->toBeNull(); + }); + }); + + describe('inheritance', function () { + it('extends Exception', function () { + $exception = createTestException('Test'); + + expect($exception)->toBeInstanceOf(Exception::class); + }); + + it('is throwable', function () { + $exception = createTestException('Test'); + + expect($exception)->toBeInstanceOf(Throwable::class); + }); + }); +}); diff --git a/tests/Unit/Exceptions/NetworkExceptionTest.php b/tests/Unit/Exceptions/NetworkExceptionTest.php new file mode 100644 index 0000000..44008d9 --- /dev/null +++ b/tests/Unit/Exceptions/NetworkExceptionTest.php @@ -0,0 +1,128 @@ +getOperation())->toBe('fetch repositories'); + }); + + it('combines operation and message in full message', function () { + $exception = new NetworkException('fetch repositories', 'Connection refused'); + + expect($exception->getMessage())->toBe('Network error during fetch repositories: Connection refused'); + }); + + it('defaults to code 0', function () { + $exception = new NetworkException('test', 'error'); + + expect($exception->getCode())->toBe(0); + }); + + it('accepts custom code', function () { + $exception = new NetworkException('test', 'error', 500); + + expect($exception->getCode())->toBe(500); + }); + + it('accepts previous exception', function () { + $previous = new Exception('Original error'); + $exception = new NetworkException('test', 'error', 0, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('includes operation and original message in context', function () { + $exception = new NetworkException('fetch pull requests', 'DNS lookup failed'); + + $context = $exception->getContext(); + expect($context)->toHaveKey('operation') + ->and($context)->toHaveKey('original_message') + ->and($context['operation'])->toBe('fetch pull requests') + ->and($context['original_message'])->toBe('DNS lookup failed'); + }); + }); + + describe('getOperation', function () { + it('returns the operation name', function () { + $exception = new NetworkException('create issue', 'Timeout'); + + expect($exception->getOperation())->toBe('create issue'); + }); + }); + + describe('timeout', function () { + it('creates timeout exception with formatted message', function () { + $exception = NetworkException::timeout('API call', 30); + + expect($exception->getMessage())->toBe('Network error during API call: Request timed out after 30 seconds'); + }); + + it('sets 408 status code for timeout', function () { + $exception = NetworkException::timeout('test', 10); + + expect($exception->getCode())->toBe(408); + }); + + it('includes operation in context', function () { + $exception = NetworkException::timeout('fetch user', 60); + + expect($exception->getOperation())->toBe('fetch user'); + }); + + it('handles various timeout values', function () { + $exception1 = NetworkException::timeout('test', 1); + $exception2 = NetworkException::timeout('test', 120); + + expect($exception1->getMessage())->toContain('1 seconds') + ->and($exception2->getMessage())->toContain('120 seconds'); + }); + }); + + describe('connectionFailed', function () { + it('creates connection failed exception without reason', function () { + $exception = NetworkException::connectionFailed('GitHub API'); + + expect($exception->getMessage())->toBe('Network error during GitHub API: Connection failed'); + }); + + it('creates connection failed exception with reason', function () { + $exception = NetworkException::connectionFailed('GitHub API', 'SSL certificate expired'); + + expect($exception->getMessage())->toBe('Network error during GitHub API: Connection failed: SSL certificate expired'); + }); + + it('sets 503 status code', function () { + $exception = NetworkException::connectionFailed('test'); + + expect($exception->getCode())->toBe(503); + }); + + it('includes operation in context', function () { + $exception = NetworkException::connectionFailed('update repo'); + + expect($exception->getOperation())->toBe('update repo'); + }); + }); + + describe('inheritance', function () { + it('extends GithubClientException', function () { + $exception = new NetworkException('test', 'error'); + + expect($exception)->toBeInstanceOf(GithubClientException::class); + }); + + it('inherits addContext functionality', function () { + $exception = new NetworkException('test', 'error'); + $exception->addContext('retry_count', 3); + + $context = $exception->getContext(); + expect($context)->toHaveKey('retry_count') + ->and($context['retry_count'])->toBe(3); + }); + }); +}); diff --git a/tests/Unit/Exceptions/RateLimitExceptionTest.php b/tests/Unit/Exceptions/RateLimitExceptionTest.php new file mode 100644 index 0000000..c75a1a1 --- /dev/null +++ b/tests/Unit/Exceptions/RateLimitExceptionTest.php @@ -0,0 +1,168 @@ +getRemainingRequests())->toBe(10); + }); + + it('sets reset time', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getResetTime())->toBe($resetTime); + }); + + it('sets total limit', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getTotalLimit())->toBe(5000); + }); + + it('generates default message when empty', function () { + $resetTime = new DateTimeImmutable('2024-01-15 12:30:00', new DateTimeZone('UTC')); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getMessage())->toContain('GitHub API rate limit exceeded') + ->and($exception->getMessage())->toContain('0/5000 requests remaining') + ->and($exception->getMessage())->toContain('2024-01-15 12:30:00'); + }); + + it('uses custom message when provided', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000, 'Custom rate limit message'); + + expect($exception->getMessage())->toBe('Custom rate limit message'); + }); + + it('defaults to 429 status code', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getCode())->toBe(429); + }); + + it('accepts custom status code', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000, '', 403); + + expect($exception->getCode())->toBe(403); + }); + + it('accepts previous exception', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $previous = new Exception('Original'); + $exception = new RateLimitException(0, $resetTime, 5000, '', 429, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('includes rate limit details in context', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(100, $resetTime, 5000); + + $context = $exception->getContext(); + expect($context)->toHaveKey('remaining_requests') + ->and($context)->toHaveKey('reset_time') + ->and($context)->toHaveKey('total_limit') + ->and($context)->toHaveKey('seconds_until_reset') + ->and($context['remaining_requests'])->toBe(100) + ->and($context['total_limit'])->toBe(5000); + }); + }); + + describe('getRemainingRequests', function () { + it('returns the remaining requests count', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(42, $resetTime, 5000); + + expect($exception->getRemainingRequests())->toBe(42); + }); + + it('handles zero remaining requests', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getRemainingRequests())->toBe(0); + }); + }); + + describe('getResetTime', function () { + it('returns the reset time', function () { + $resetTime = new DateTimeImmutable('2024-06-15 15:00:00'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getResetTime())->toBe($resetTime); + }); + }); + + describe('getTotalLimit', function () { + it('returns the total limit for authenticated users', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getTotalLimit())->toBe(5000); + }); + + it('returns the total limit for unauthenticated users', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 60); + + expect($exception->getTotalLimit())->toBe(60); + }); + }); + + describe('getSecondsUntilReset', function () { + it('calculates seconds until reset', function () { + $resetTime = new DateTimeImmutable('+3600 seconds'); + $exception = new RateLimitException(0, $resetTime, 5000); + + $seconds = $exception->getSecondsUntilReset(); + // Allow some tolerance for test execution time + expect($seconds)->toBeGreaterThan(3590) + ->and($seconds)->toBeLessThanOrEqual(3600); + }); + + it('returns zero for past reset times', function () { + $resetTime = new DateTimeImmutable('-1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception->getSecondsUntilReset())->toBe(0); + }); + + it('handles reset time in near future', function () { + $resetTime = new DateTimeImmutable('+10 seconds'); + $exception = new RateLimitException(0, $resetTime, 5000); + + $seconds = $exception->getSecondsUntilReset(); + expect($seconds)->toBeGreaterThanOrEqual(5) + ->and($seconds)->toBeLessThanOrEqual(10); + }); + }); + + describe('inheritance', function () { + it('extends GithubClientException', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + + expect($exception)->toBeInstanceOf(GithubClientException::class); + }); + + it('inherits addContext functionality', function () { + $resetTime = new DateTimeImmutable('+1 hour'); + $exception = new RateLimitException(0, $resetTime, 5000); + $exception->addContext('endpoint', '/repos'); + + $context = $exception->getContext(); + expect($context)->toHaveKey('endpoint') + ->and($context['endpoint'])->toBe('/repos'); + }); + }); +}); diff --git a/tests/Unit/Exceptions/ValidationExceptionTest.php b/tests/Unit/Exceptions/ValidationExceptionTest.php new file mode 100644 index 0000000..381c854 --- /dev/null +++ b/tests/Unit/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,149 @@ +getField())->toBe('per_page'); + }); + + it('sets value correctly', function () { + $exception = new ValidationException('per_page', 200, 'Must be between 1 and 100'); + + expect($exception->getValue())->toBe(200); + }); + + it('combines field and message in full message', function () { + $exception = new ValidationException('per_page', 200, 'Must be between 1 and 100'); + + expect($exception->getMessage())->toBe("Validation failed for field 'per_page': Must be between 1 and 100"); + }); + + it('defaults to 422 status code', function () { + $exception = new ValidationException('field', 'value', 'error'); + + expect($exception->getCode())->toBe(422); + }); + + it('accepts custom status code', function () { + $exception = new ValidationException('field', 'value', 'error', 400); + + expect($exception->getCode())->toBe(400); + }); + + it('accepts previous exception', function () { + $previous = new Exception('Original'); + $exception = new ValidationException('field', 'value', 'error', 422, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('includes field, value, and validation message in context', function () { + $exception = new ValidationException('issue_number', -5, 'Must be a positive integer'); + + $context = $exception->getContext(); + expect($context)->toHaveKey('field') + ->and($context)->toHaveKey('value') + ->and($context)->toHaveKey('validation_message') + ->and($context['field'])->toBe('issue_number') + ->and($context['value'])->toBe(-5) + ->and($context['validation_message'])->toBe('Must be a positive integer'); + }); + }); + + describe('getField', function () { + it('returns the field name', function () { + $exception = new ValidationException('state', 'invalid', 'Must be open or closed'); + + expect($exception->getField())->toBe('state'); + }); + }); + + describe('getValue', function () { + it('returns string value', function () { + $exception = new ValidationException('state', 'invalid', 'error'); + + expect($exception->getValue())->toBe('invalid'); + }); + + it('returns integer value', function () { + $exception = new ValidationException('page', -1, 'Must be positive'); + + expect($exception->getValue())->toBe(-1); + }); + + it('returns float value', function () { + $exception = new ValidationException('rate', 1.5, 'Must be integer'); + + expect($exception->getValue())->toBe(1.5); + }); + + it('returns null value', function () { + $exception = new ValidationException('required_field', null, 'Cannot be null'); + + expect($exception->getValue())->toBeNull(); + }); + + it('returns array value', function () { + $exception = new ValidationException('labels', ['a', 'b', 'c', 'd', 'e', 'f'], 'Maximum 5 labels allowed'); + + expect($exception->getValue())->toBe(['a', 'b', 'c', 'd', 'e', 'f']); + }); + + it('returns boolean value', function () { + $exception = new ValidationException('is_template', 'yes', 'Must be boolean'); + + expect($exception->getValue())->toBe('yes'); + }); + }); + + describe('common validation scenarios', function () { + it('handles invalid repository name', function () { + $exception = new ValidationException('repository', 'invalid/format/here', 'Repository must be in owner/repo format'); + + expect($exception->getField())->toBe('repository') + ->and($exception->getMessage())->toContain('owner/repo format'); + }); + + it('handles invalid pagination', function () { + $exception = new ValidationException('per_page', 500, 'Must be between 1 and 100'); + + expect($exception->getCode())->toBe(422) + ->and($exception->getValue())->toBe(500); + }); + + it('handles missing required field', function () { + $exception = new ValidationException('title', '', 'Title is required'); + + expect($exception->getValue())->toBe('') + ->and($exception->getMessage())->toContain('Title is required'); + }); + + it('handles invalid enum value', function () { + $exception = new ValidationException('state', 'pending', 'State must be one of: open, closed'); + + expect($exception->getMessage())->toContain('open, closed'); + }); + }); + + describe('inheritance', function () { + it('extends GithubClientException', function () { + $exception = new ValidationException('field', 'value', 'error'); + + expect($exception)->toBeInstanceOf(GithubClientException::class); + }); + + it('inherits addContext functionality', function () { + $exception = new ValidationException('field', 'value', 'error'); + $exception->addContext('suggestion', 'Use a valid value'); + + $context = $exception->getContext(); + expect($context)->toHaveKey('suggestion') + ->and($context['suggestion'])->toBe('Use a valid value'); + }); + }); +});