diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 9dfadc9..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,95 +0,0 @@ -# Dependabot configuration for automated dependency updates -# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates - -version: 2 - -updates: - # Composer dependencies (PHP) - - package-ecosystem: "composer" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - timezone: "UTC" - open-pull-requests-limit: 10 - reviewers: - - "Thavarshan" - assignees: - - "Thavarshan" - commit-message: - prefix: "deps" - prefix-development: "deps-dev" - include: "scope" - labels: - - "dependencies" - - "composer" - ignore: - # Ignore patch updates for stable packages - - dependency-name: "guzzlehttp/guzzle" - update-types: ["version-update:semver-patch"] - - dependency-name: "react/*" - update-types: ["version-update:semver-patch"] - groups: - guzzle: - patterns: - - "guzzlehttp/*" - react: - patterns: - - "react/*" - testing: - patterns: - - "phpunit/*" - - "mockery/*" - dev-tools: - patterns: - - "phpstan/*" - - "laravel/pint" - - "friendsofphp/php-cs-fixer" - - "tightenco/duster" - - # NPM dependencies (Documentation) - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:30" - timezone: "UTC" - open-pull-requests-limit: 5 - reviewers: - - "Thavarshan" - commit-message: - prefix: "deps(npm)" - include: "scope" - labels: - - "dependencies" - - "npm" - ignore: - # Major version updates need manual review - - dependency-name: "*" - update-types: ["version-update:semver-major"] - - # GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "sunday" - time: "06:00" - timezone: "UTC" - open-pull-requests-limit: 5 - reviewers: - - "Thavarshan" - commit-message: - prefix: "ci" - include: "scope" - labels: - - "dependencies" - - "github-actions" - groups: - actions: - patterns: - - "actions/*" - exclude-patterns: - - "actions/checkout" # Keep checkout updates separate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38905ea..93efbf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,12 @@ jobs: ${{ runner.os }}-php${{ matrix.php }}-composer- - name: Install dependencies - run: composer install --no-interaction --prefer-dist --optimize-autoloader ${{ matrix.stability == 'prefer-lowest' && '--prefer-lowest' || '' }} + if: matrix.stability != 'prefer-lowest' + run: composer install --no-interaction --prefer-dist --optimize-autoloader ${{ matrix.os == 'windows-latest' && '--ignore-platform-req=ext-pcntl --ignore-platform-req=ext-posix' || '' }} + + - name: Install dependencies (prefer-lowest) + if: matrix.stability == 'prefer-lowest' + run: composer update --no-interaction --prefer-dist --optimize-autoloader --prefer-lowest ${{ matrix.os == 'windows-latest' && '--ignore-platform-req=ext-pcntl --ignore-platform-req=ext-posix' || '' }} - name: Execute tests run: composer test diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index 252104e..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: dependabot-auto-merge -on: pull_request_target - -permissions: - pull-requests: write - contents: write - -jobs: - dependabot: - runs-on: ubuntu-latest - if: ${{ github.actor == 'dependabot[bot]' }} - steps: - - name: Dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@v2.4.0 - with: - github-token: '${{ secrets.GITHUB_TOKEN }}' - compat-lookup: true - - - name: Auto-merge Dependabot PRs for semver-minor updates - if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - - name: Auto-merge Dependabot PRs for semver-patch updates - if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - - name: Auto-merge Dependabot PRs for Action major versions when compatibility is higher than 90% - if: ${{steps.metadata.outputs.package-ecosystem == 'github_actions' && steps.metadata.outputs.update-type == 'version-update:semver-major' && steps.metadata.outputs.compatibility-score >= 90}} - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 64d8f1f..9623d78 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -26,4 +26,4 @@ jobs: with: comment-summary-in-pr: always fail-on-severity: moderate - allow-dependencies-licenses: GPL-3.0-or-later, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, LGPL-2.1, LGPL-3.0 + allow-licenses: GPL-3.0-or-later, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, LGPL-2.1, LGPL-3.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index c19b9e7..e041d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,45 @@ # Release Notes -## [Unreleased](https://github.com/Thavarshan/fetch-php/compare/v3.2.3...HEAD) +## [Unreleased](https://github.com/Thavarshan/fetch-php/compare/v3.3.0...HEAD) + +## [v3.3.0](https://github.com/Thavarshan/fetch-php/compare/v3.2.3...v3.3.0) - 2025-01-17 + +### Added + +- **Comprehensive Testing Utilities** inspired by Laravel's HTTP client testing: + - `MockServer` with URL pattern matching (wildcards, method-specific, callbacks) + - `MockResponse` fluent builder with convenience methods for all HTTP status codes + - `MockResponseSequence` for testing retry logic and flaky endpoints + - `Recorder` for capturing and replaying request/response pairs + - Comprehensive assertion helpers (`assertSent()`, `assertNotSent()`, `assertSentCount()`, `assertNothingSent()`) + - Request recording with JSON export/import for test fixtures + - Stray request prevention with allowlist support +- `HandlesMocking` trait integrated into `ClientHandler` for request interception +- `Request::createFromBase()` method for PSR-7 request conversion +- 117 new tests with 288 assertions for testing utilities +- Comprehensive testing documentation in `docs/guide/testing.md` (617 lines) +- Complete API reference in `docs/api/testing.md` (539 lines) +- Testing utilities section in VitePress sidebar navigation + +### Changed + +- Updated `PerformsHttpRequests` to support mock request interception +- Enhanced `ClientHandler` with `HandlesMocking` trait for testing support + +### Fixed + +- Fixed async helper function imports to use correct `Matrix\Support\*` namespace: + - Updated imports in `ManagesPromises` trait (`async`, `await`, `all`, `any`, `race`, `reject`, `resolve`, `timeout`) + - Updated imports in `PerformsHttpRequests` trait (`async`) + - Updated imports in `ManagesPromisesTest` and `AsyncRequestsTest` +- All 282 tests now passing with 841 assertions + +### Removed + +- Removed Dependabot configuration (`dependabot.yml`) +- Removed Dependabot auto-merge workflow + +**Full Changelog**: ## [v3.2.3](https://github.com/Thavarshan/fetch-php/compare/v3.2.2...v3.2.3) - 2025-05-24 @@ -338,20 +377,3 @@ ## v1.0.0 - 2024-09-14 Initial release. - -## [v3.2.2](https://github.com/Thavarshan/fetch-php/compare/v3.2.1...v3.2.2) - 2025-05-19 - -### Fixed - -- "Fatal error: Uncaught Error: Interface `Psr\Log\LoggerAwareInterface` not found" closes #21 - -**Full Changelog**: [https://github.com/Thavarshan/fetch-php/compare/3.2.1...3.2.2](https://github.com/Thavarshan/fetch-php/compare/3.2.1...3.2.2) - -## [v3.2.1](https://github.com/Thavarshan/fetch-php/compare/v3.2.1...v3.2.1) - 2025-05-17 - -### Changed - -- Updated documentation -- Updated dependencies - -**Full Changelog**: [https://github.com/Thavarshan/fetch-php/compare/3.2.0...3.2.1](https://github.com/Thavarshan/fetch-php/compare/3.2.0...3.2.1) diff --git a/composer.json b/composer.json index 6f344f7..44f20e1 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "jerome/fetch-php", "description": "The JavaScript fetch API for PHP.", - "version": "3.2.3", + "version": "3.3.0", "type": "library", "license": "MIT", "authors": [ @@ -28,7 +28,7 @@ "ext-pcntl": "*", "guzzlehttp/guzzle": "^7.9", "guzzlehttp/psr7": "^2.7", - "jerome/matrix": "^3.3", + "jerome/matrix": "^3.4", "psr/http-message": "^1.0|^2.0", "psr/log": "^1.0|^2.0|^3.0", "react/event-loop": "^1.5", @@ -52,10 +52,10 @@ "scripts": { "lint": "duster lint src", "fix": "duster fix src", - "test": "phpunit --do-not-fail-on-skipped --no-coverage", - "test:unit": "phpunit --do-not-fail-on-skipped --no-coverage tests/Unit", - "test:integration": "phpunit --do-not-fail-on-skipped --no-coverage tests/Integration", - "test:coverage": "XDEBUG_MODE=coverage phpunit --do-not-fail-on-skipped --coverage-html=coverage --coverage-text --coverage-xml=coverage", + "test": "phpunit --no-coverage", + "test:unit": "phpunit --no-coverage tests/Unit", + "test:integration": "phpunit --no-coverage tests/Integration", + "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-html=coverage --coverage-text --coverage-xml=coverage", "analyse": "phpstan analyse", "check": [ "@analyse", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 77e79ed..7332ce6 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -241,6 +241,12 @@ export default defineConfig({ { text: "Status", link: "/api/status-enum" }, ], }, + { + text: "Testing", + items: [ + { text: "Testing Utilities", link: "/api/testing" }, + ], + }, ], "/examples/": [ { diff --git a/docs/api/testing.md b/docs/api/testing.md new file mode 100644 index 0000000..a026484 --- /dev/null +++ b/docs/api/testing.md @@ -0,0 +1,539 @@ +--- +title: Testing API +description: API reference for Fetch PHP testing utilities +--- + +# Testing API + +Comprehensive testing utilities for mocking HTTP requests, recording/replaying responses, and making assertions. + +## MockServer + +The MockServer class provides a powerful interface for mocking HTTP requests in tests. + +### Static Methods + +#### `fake()` + +Set up fake responses for HTTP requests. + +```php +// Mock all requests with empty 200 responses +MockServer::fake(); + +// Mock specific URLs +MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json(['users' => []]), + 'POST https://api.example.com/users' => MockResponse::created(['id' => 123]), +]); + +// Use callback for dynamic responses +MockServer::fake(function ($request) { + return MockResponse::json(['dynamic' => true]); +}); +``` + +**Parameters:** +- `$patterns` (array|Closure|null): URL patterns mapped to responses, or a callback + +#### `preventStrayRequests()` + +Prevent requests that don't match any registered fakes. + +```php +MockServer::preventStrayRequests(); + +get('https://unmocked-url.com'); // Throws InvalidArgumentException +``` + +#### `allowStrayRequests()` + +Allow stray requests to specific URL patterns. + +```php +MockServer::allowStrayRequests([ + 'https://localhost/*', + 'http://127.0.0.1:*', +]); +``` + +**Parameters:** +- `$patterns` (array): URL patterns to allow + +#### `recorded()` + +Get all recorded requests and responses. + +```php +$records = MockServer::recorded(); + +// With filter +$postRecords = MockServer::recorded(function ($record) { + return $record['request']->getMethod() === 'POST'; +}); +``` + +**Parameters:** +- `$filter` (Closure|null): Optional filter callback + +**Returns:** `array` + +#### `assertSent()` + +Assert that a request matching the criteria was sent. + +```php +// By URL pattern +MockServer::assertSent('https://api.example.com/users'); +MockServer::assertSent('POST https://api.example.com/users'); + +// With callback +MockServer::assertSent(function ($request, $response) { + return $request->hasHeader('Authorization'); +}); + +// Specific number of times +MockServer::assertSent('https://api.example.com/users', 2); +``` + +**Parameters:** +- `$pattern` (string|Closure): URL pattern or callback +- `$times` (int|null): Expected number of times (null = at least once) + +#### `assertNotSent()` + +Assert that a request matching the criteria was not sent. + +```php +MockServer::assertNotSent('https://api.example.com/posts'); + +MockServer::assertNotSent(function ($request) { + return $request->getMethod() === 'DELETE'; +}); +``` + +**Parameters:** +- `$pattern` (string|Closure): URL pattern or callback + +#### `assertSentCount()` + +Assert the exact number of requests sent. + +```php +MockServer::assertSentCount(3); +``` + +**Parameters:** +- `$count` (int): Expected number of requests + +#### `assertNothingSent()` + +Assert that no requests were sent. + +```php +MockServer::assertNothingSent(); +``` + +#### `resetInstance()` + +Reset the MockServer singleton instance. + +```php +protected function tearDown(): void +{ + MockServer::resetInstance(); + parent::tearDown(); +} +``` + +## MockResponse + +Fluent builder for creating mock HTTP responses. + +### Static Factory Methods + +#### `create()` + +Create a basic mock response. + +```php +$response = MockResponse::create(200, 'Hello World', ['X-Custom' => 'value']); +``` + +**Parameters:** +- `$status` (int): HTTP status code (default: 200) +- `$body` (mixed): Response body (default: '') +- `$headers` (array): Response headers (default: []) + +**Returns:** `MockResponse` + +#### `json()` + +Create a JSON response. + +```php +$response = MockResponse::json(['name' => 'John'], 200); +``` + +**Parameters:** +- `$data` (array|object): Data to encode as JSON +- `$status` (int): HTTP status code (default: 200) +- `$headers` (array): Additional headers (default: []) + +**Returns:** `MockResponse` + +#### `sequence()` + +Create a response sequence. + +```php +$sequence = MockResponse::sequence() + ->push(500, 'Error') + ->push(200, 'Success'); +``` + +**Parameters:** +- `$responses` (array): Initial responses (default: []) + +**Returns:** `MockResponseSequence` + +### Convenience Methods + +#### Success Responses + +```php +MockResponse::ok($body, $headers) // 200 +MockResponse::created($body, $headers) // 201 +MockResponse::noContent($headers) // 204 +``` + +#### Client Error Responses + +```php +MockResponse::badRequest($body, $headers) // 400 +MockResponse::unauthorized($body, $headers) // 401 +MockResponse::forbidden($body, $headers) // 403 +MockResponse::notFound($body, $headers) // 404 +MockResponse::unprocessableEntity($body, $headers) // 422 +``` + +#### Server Error Responses + +```php +MockResponse::serverError($body, $headers) // 500 +MockResponse::serviceUnavailable($body, $headers) // 503 +``` + +### Instance Methods + +#### `delay()` + +Set a delay before returning the response. + +```php +$response = MockResponse::ok()->delay(100); // 100ms delay +``` + +**Parameters:** +- `$milliseconds` (int): Delay in milliseconds + +**Returns:** `self` + +#### `throw()` + +Throw an exception instead of returning a response. + +```php +$response = MockResponse::ok()->throw(new \RuntimeException('Network error')); +``` + +**Parameters:** +- `$throwable` (Throwable): Exception to throw + +**Returns:** `self` + +## MockResponseSequence + +Manages a sequence of responses for testing retry logic and flaky endpoints. + +### Instance Methods + +#### `push()` + +Add a response to the sequence. + +```php +$sequence->push(200, 'First response', ['X-Header' => 'value']); +``` + +**Parameters:** +- `$status` (int): HTTP status code (default: 200) +- `$body` (mixed): Response body (default: '') +- `$headers` (array): Response headers (default: []) + +**Returns:** `self` + +#### `pushJson()` + +Add a JSON response to the sequence. + +```php +$sequence->pushJson(['data' => 'value'], 201); +``` + +**Parameters:** +- `$data` (array|object): Data to encode as JSON +- `$status` (int): HTTP status code (default: 200) +- `$headers` (array): Additional headers (default: []) + +**Returns:** `self` + +#### `pushStatus()` + +Add a status-only response to the sequence. + +```php +$sequence->pushStatus(404); +``` + +**Parameters:** +- `$status` (int): HTTP status code +- `$headers` (array): Response headers (default: []) + +**Returns:** `self` + +#### `pushResponse()` + +Add a MockResponse instance to the sequence. + +```php +$sequence->pushResponse(MockResponse::ok('Test')); +``` + +**Parameters:** +- `$response` (MockResponse): MockResponse instance + +**Returns:** `self` + +#### `whenEmpty()` + +Set the default response when the sequence is exhausted. + +```php +$sequence->whenEmpty(MockResponse::ok('default')); +``` + +**Parameters:** +- `$response` (MockResponse): Default response + +**Returns:** `self` + +#### `loop()` + +Make the sequence loop back to the beginning when exhausted. + +```php +$sequence->loop(); +``` + +**Returns:** `self` + +#### `reset()` + +Reset the sequence to the beginning. + +```php +$sequence->reset(); +``` + +**Returns:** `self` + +## Recorder + +Record and replay HTTP requests and responses. + +### Static Methods + +#### `start()` + +Start recording requests and responses. + +```php +Recorder::start(); +``` + +#### `stop()` + +Stop recording and return the recordings. + +```php +$recordings = Recorder::stop(); +``` + +**Returns:** `array` + +#### `replay()` + +Replay recordings by setting up mock responses. + +```php +Recorder::replay($recordings); +``` + +**Parameters:** +- `$recordings` (array): Recordings to replay + +#### `exportToJson()` + +Export recordings to JSON format. + +```php +$json = Recorder::exportToJson(); +file_put_contents('recordings.json', $json); +``` + +**Returns:** `string` + +#### `importFromJson()` + +Import recordings from JSON and replay them. + +```php +$json = file_get_contents('recordings.json'); +Recorder::importFromJson($json); +``` + +**Parameters:** +- `$json` (string): JSON string of recordings + +**Throws:** `InvalidArgumentException` if JSON is invalid + +#### `clear()` + +Clear all recordings. + +```php +Recorder::clear(); +``` + +#### `isRecording()` + +Check if recording is currently active. + +```php +if (Recorder::isRecording()) { + // Recording is active +} +``` + +**Returns:** `bool` + +#### `getRecordings()` + +Get all current recordings. + +```php +$recordings = Recorder::getRecordings(); +``` + +**Returns:** `array` + +#### `reset()` + +Reset the recorder state. + +```php +Recorder::reset(); +``` + +#### `resetInstance()` + +Reset the singleton instance completely. + +```php +Recorder::resetInstance(); +``` + +## Usage Examples + +### Basic Test Setup + +```php +use PHPUnit\Framework\TestCase; +use Fetch\Testing\MockServer; +use Fetch\Testing\MockResponse; + +class MyServiceTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json(['users' => []]), + ]); + } + + protected function tearDown(): void + { + MockServer::resetInstance(); + parent::tearDown(); + } + + public function test_fetches_users(): void + { + $response = get('https://api.example.com/users'); + + $this->assertEquals(200, $response->status()); + MockServer::assertSent('https://api.example.com/users'); + } +} +``` + +### Testing Retry Logic + +```php +public function test_retries_on_failure(): void +{ + MockServer::fake([ + 'https://api.example.com/unstable' => MockResponse::sequence() + ->pushStatus(503) + ->pushStatus(503) + ->pushStatus(200), + ]); + + $response = retry(fn() => get('https://api.example.com/unstable'), 3); + + $this->assertEquals(200, $response->status()); + MockServer::assertSent('https://api.example.com/unstable', 3); +} +``` + +### Recording and Replaying + +```php +public function test_records_and_replays(): void +{ + // Record real requests + MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json(['id' => 1]), + ]); + + Recorder::start(); + get('https://api.example.com/users'); + $recordings = Recorder::stop(); + + // Reset and replay + MockServer::resetInstance(); + Recorder::replay($recordings); + + $response = get('https://api.example.com/users'); + $this->assertEquals(['id' => 1], $response->json()); +} +``` + +## See Also + +- [Testing Guide](/guide/testing) - Complete testing guide with examples +- [Error Handling](/guide/error-handling) - Testing error scenarios +- [Retry Handling](/guide/retry-handling) - Testing retry logic diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 9231b70..3958bb4 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -1,682 +1,617 @@ --- -title: Testing -description: Learn how to test code that uses the Fetch HTTP package +title: Testing with Mocks +description: Learn how to test HTTP-dependent code using Fetch PHP's powerful testing utilities --- -# Testing +# Testing with Mocks -This guide explains how to test code that uses the Fetch HTTP package. Properly testing HTTP-dependent code is crucial for creating reliable applications. +Fetch PHP provides comprehensive testing utilities including a powerful mock server, request recording/playback, and advanced assertion helpers for HTTP testing. -## Mock Responses +## Quick Start -The Fetch HTTP package provides built-in utilities for creating mock responses: +The simplest way to get started with mocking: ```php -use Fetch\Http\ClientHandler; -use Fetch\Enum\Status; - -// Create a basic mock response -$mockResponse = ClientHandler::createMockResponse( - 200, // Status code - ['Content-Type' => 'application/json'], // Headers - '{"name": "John Doe", "email": "john@example.com"}' // Body -); - -// Using Status enum -$mockResponse = ClientHandler::createMockResponse( - Status::OK, // Status code as enum - ['Content-Type' => 'application/json'], - '{"name": "John Doe", "email": "john@example.com"}' -); - -// Create a JSON response directly from PHP data -$mockJsonResponse = ClientHandler::createJsonResponse( - ['name' => 'Jane Doe', 'email' => 'jane@example.com'], // Data (will be JSON-encoded) - 201, // Status code - ['X-Custom-Header' => 'Value'] // Additional headers -); - -// Using Status enum -$mockJsonResponse = ClientHandler::createJsonResponse( - ['name' => 'Jane Doe', 'email' => 'jane@example.com'], - Status::CREATED -); -``` - -## Mock Client with Guzzle MockHandler +use Fetch\Testing\MockServer; +use Fetch\Testing\MockResponse; -For testing code that uses the Fetch HTTP package, you can set up a mock handler to return predefined responses: - -```php -use Fetch\Http\ClientHandler; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Client; - -// Create a mock handler with an array of responses -$mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], '{"id": 1, "name": "Test User"}'), - new Response(404, [], '{"error": "Not found"}'), - new Response(500, [], '{"error": "Server error"}') +// Set up fake responses +MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json(['users' => []]), ]); -// Create a handler stack with the mock handler -$stack = HandlerStack::create($mock); +// Make requests (they will be mocked) +$response = get('https://api.example.com/users'); -// Create a Guzzle client with the stack -$guzzleClient = new Client(['handler' => $stack]); +// Assert requests were sent +MockServer::assertSent('https://api.example.com/users'); +``` + +## MockServer -// Create a ClientHandler with the mock client -$client = ClientHandler::createWithClient($guzzleClient); +### Basic Mocking -// First request will return 200 response -$response1 = $client->get('https://api.example.com/users/1'); -echo $response1->status(); // 200 -echo $response1->json()['name']; // "Test User" +Mock all requests with empty 200 responses: -// Second request will return 404 response -$response2 = $client->get('https://api.example.com/users/999'); -echo $response2->status(); // 404 +```php +MockServer::fake(); -// Third request will return 500 response -$response3 = $client->get('https://api.example.com/error'); -echo $response3->status(); // 500 +$response = get('https://any-url.com'); // Returns 200 OK ``` -## Testing a Service Class +### URL Pattern Matching -Here's how to test a service class that uses the Fetch HTTP package: +Mock specific URLs: ```php -use PHPUnit\Framework\TestCase; -use Fetch\Http\ClientHandler; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Client; +MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json([ + 'users' => ['John', 'Jane'] + ]), + 'https://api.example.com/posts' => MockResponse::json([ + 'posts' => [] + ]), +]); +``` -class UserService -{ - private ClientHandler $client; +### HTTP Method Matching - public function __construct(ClientHandler $client) - { - $this->client = $client; - } +Match specific HTTP methods: - public function getUser(int $id): array - { - $response = $this->client->get("/users/{$id}"); +```php +MockServer::fake([ + 'GET https://api.example.com/users' => MockResponse::json(['users' => []]), + 'POST https://api.example.com/users' => MockResponse::created(['id' => 123]), + 'PUT https://api.example.com/users/123' => MockResponse::ok(['updated' => true]), + 'DELETE https://api.example.com/users/123' => MockResponse::noContent(), +]); +``` - if ($response->isNotFound()) { - throw new \RuntimeException("User {$id} not found"); - } +### Wildcard Patterns - return $response->json(); - } +Use wildcards for flexible matching: - public function createUser(array $userData): array - { - $response = $this->client->post('/users', $userData); +```php +MockServer::fake([ + 'https://api.example.com/users/*' => MockResponse::json(['user' => 'found']), + 'https://api.example.com/posts/*' => MockResponse::json(['post' => 'found']), + '*' => MockResponse::notFound(), // Catch-all fallback +]); - if (!$response->successful()) { - throw new \RuntimeException("Failed to create user: " . $response->status()); - } +$response1 = get('https://api.example.com/users/123'); // Matches +$response2 = get('https://api.example.com/users/456'); // Matches +``` - return $response->json(); - } -} +### Dynamic Responses with Callbacks -class UserServiceTest extends TestCase -{ - private function createMockClient(array $responses): ClientHandler - { - $mock = new MockHandler($responses); - $stack = HandlerStack::create($mock); - $guzzleClient = new Client(['handler' => $stack]); +Use callbacks for dynamic response generation: - return ClientHandler::createWithClient($guzzleClient); +```php +MockServer::fake(function ($request) { + // Check authentication + if ($request->hasHeader('Authorization')) { + return MockResponse::json(['authenticated' => true]); } - public function testGetUserReturnsUserData(): void - { - // Arrange - $expectedUser = ['id' => 1, 'name' => 'Test User']; - $mockResponses = [ - new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedUser)) - ]; - $client = $this->createMockClient($mockResponses); - $userService = new UserService($client); - - // Act - $user = $userService->getUser(1); - - // Assert - $this->assertEquals($expectedUser, $user); + // Check URL + if (str_contains((string) $request->getUri(), 'users')) { + return MockResponse::json(['users' => []]); } - public function testGetUserThrowsExceptionForNotFound(): void - { - // Arrange - $mockResponses = [ - new Response(404, ['Content-Type' => 'application/json'], '{"error": "Not found"}') - ]; - $client = $this->createMockClient($mockResponses); - $userService = new UserService($client); - - // Assert & Act - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('User 999 not found'); - - $userService->getUser(999); + // Check method + if ($request->getMethod() === 'POST') { + return MockResponse::created(); } - public function testCreateUserReturnsCreatedUser(): void - { - // Arrange - $userData = ['name' => 'New User', 'email' => 'new@example.com']; - $expectedUser = array_merge(['id' => 123], $userData); - $mockResponses = [ - new Response(201, ['Content-Type' => 'application/json'], json_encode($expectedUser)) - ]; - $client = $this->createMockClient($mockResponses); - $userService = new UserService($client); - - // Act - $user = $userService->createUser($userData); - - // Assert - $this->assertEquals($expectedUser, $user); - } -} + return MockResponse::unauthorized(); +}); ``` -## Testing History +## MockResponse -You can also use `GuzzleHttp\Middleware::history()` to capture request/response history for testing: +### Creating Responses ```php -use GuzzleHttp\Middleware; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Client; -use Fetch\Http\ClientHandler; -use Psr\Http\Message\RequestInterface; +use Fetch\Testing\MockResponse; -class ClientHistoryTest extends \PHPUnit\Framework\TestCase -{ - public function testRequestContainsExpectedHeaders(): void - { - // Set up a history container - $container = []; - $history = Middleware::history($container); - - // Create a stack with the history middleware - $stack = HandlerStack::create(); - $stack->push($history); - - // Add a mock response - $mock = new \GuzzleHttp\Handler\MockHandler([ - new \GuzzleHttp\Psr7\Response(200, [], '{}') - ]); - $stack->setHandler($mock); - - // Create a Guzzle client with the stack - $guzzleClient = new Client(['handler' => $stack]); +// Basic response +$response = MockResponse::create(200, 'Hello World', ['X-Custom' => 'value']); - // Create a ClientHandler with the client - $client = ClientHandler::createWithClient($guzzleClient); - - // Make a request - $client->withToken('test-token') - ->withHeader('X-Custom-Header', 'CustomValue') - ->get('https://api.example.com/resource'); +// JSON response +$response = MockResponse::json(['name' => 'John', 'age' => 30], 200); +``` - // Assert request contained expected headers - $this->assertCount(1, $container); - $transaction = $container[0]; - $request = $transaction['request']; +### Convenience Methods - $this->assertEquals('GET', $request->getMethod()); - $this->assertEquals('https://api.example.com/resource', (string) $request->getUri()); - $this->assertEquals('Bearer test-token', $request->getHeaderLine('Authorization')); - $this->assertEquals('CustomValue', $request->getHeaderLine('X-Custom-Header')); - } -} +```php +// Success responses +MockResponse::ok('Success'); +MockResponse::created(['id' => 123]); +MockResponse::noContent(); + +// Client error responses +MockResponse::badRequest('Invalid input'); +MockResponse::unauthorized(); +MockResponse::forbidden(); +MockResponse::notFound(); +MockResponse::unprocessableEntity(['errors' => ['field' => 'required']]); + +// Server error responses +MockResponse::serverError('Internal error'); +MockResponse::serviceUnavailable(); ``` -## Testing Asynchronous Requests +### Response Delays -For testing asynchronous code: +Simulate slow responses: ```php -use function async; -use function await; -use function all; +MockServer::fake([ + 'https://api.example.com/slow' => MockResponse::ok('Done')->delay(100), // 100ms delay +]); -class AsyncTest extends \PHPUnit\Framework\TestCase -{ - public function testAsyncRequests(): void - { - // Create mock responses - $mock = new \GuzzleHttp\Handler\MockHandler([ - new \GuzzleHttp\Psr7\Response(200, [], '{"id":1,"name":"User 1"}'), - new \GuzzleHttp\Psr7\Response(200, [], '{"id":2,"name":"User 2"}') - ]); +$start = microtime(true); +$response = get('https://api.example.com/slow'); +$duration = (microtime(true) - $start) * 1000; +// Duration will be >= 100ms +``` - $stack = HandlerStack::create($mock); - $guzzleClient = new Client(['handler' => $stack]); - $client = ClientHandler::createWithClient($guzzleClient); +### Throwing Exceptions - // Using modern async/await pattern - $result = await(async(function() use ($client) { - $results = await(all([ - 'user1' => async(fn() => $client->get('https://api.example.com/users/1')), - 'user2' => async(fn() => $client->get('https://api.example.com/users/2')) - ])); +Simulate network errors: - return $results; - })); +```php +MockServer::fake([ + 'https://api.example.com/error' => MockResponse::ok()->throw( + new \RuntimeException('Network timeout') + ), +]); - // Assert responses - $this->assertEquals(200, $result['user1']->status()); - $this->assertEquals('User 1', $result['user1']->json()['name']); +try { + get('https://api.example.com/error'); +} catch (\RuntimeException $e) { + // Handle the error +} +``` - $this->assertEquals(200, $result['user2']->status()); - $this->assertEquals('User 2', $result['user2']->json()['name']); +## Response Sequences - // Or using traditional promise pattern - $handler = $client->getHandler(); - $handler->async(); +Test retry logic and flaky endpoints: - $promise1 = $handler->get('https://api.example.com/users/1'); - $promise2 = $handler->get('https://api.example.com/users/2'); +```php +MockServer::fake([ + 'https://api.example.com/flaky' => MockResponse::sequence() + ->pushStatus(500) // First request fails + ->pushStatus(500) // Second request fails + ->pushStatus(200), // Third request succeeds +]); - $promises = $handler->all(['user1' => $promise1, 'user2' => $promise2]); - $responses = $handler->awaitPromise($promises); +$response1 = get('https://api.example.com/flaky'); // 500 +$response2 = get('https://api.example.com/flaky'); // 500 +$response3 = get('https://api.example.com/flaky'); // 200 +``` - // Assert responses - $this->assertEquals(200, $responses['user1']->status()); - $this->assertEquals('User 1', $responses['user1']->json()['name']); +Advanced sequence features: - $this->assertEquals(200, $responses['user2']->status()); - $this->assertEquals('User 2', $responses['user2']->json()['name']); - } -} +```php +$sequence = MockResponse::sequence() + ->push(200, 'First response') + ->pushJson(['data' => 'second'], 201) + ->pushStatus(404) + ->whenEmpty(MockResponse::ok('default')) // Return this when exhausted + ->loop(); // Or loop back to the beginning + +MockServer::fake([ + 'https://api.example.com/endpoint' => $sequence, +]); ``` -## Testing with Custom Response Factory +## Assertions -You can create a helper for generating test responses: +### Assert Request Was Sent ```php -use Fetch\Http\ClientHandler; -use Fetch\Enum\Status; +MockServer::fake(['*' => MockResponse::ok()]); -class ResponseFactory -{ - public static function userResponse(int $id, string $name, string $email): \Fetch\Http\Response - { - return ClientHandler::createJsonResponse([ - 'id' => $id, - 'name' => $name, - 'email' => $email, - 'created_at' => '2023-01-01T00:00:00Z' - ]); - } +post('https://api.example.com/users', ['name' => 'John']); - public static function usersListResponse(array $users): \Fetch\Http\Response - { - return ClientHandler::createJsonResponse([ - 'data' => $users, - 'meta' => [ - 'total' => count($users), - 'page' => 1, - 'per_page' => count($users) - ] - ]); - } +// Assert by URL pattern +MockServer::assertSent('https://api.example.com/users'); +MockServer::assertSent('POST https://api.example.com/users'); - public static function errorResponse(int|Status $status, string $message): \Fetch\Http\Response - { - return ClientHandler::createJsonResponse( - ['error' => $message], - $status - ); - } +// Assert with callback +MockServer::assertSent(function ($request, $response) { + return $request->hasHeader('Authorization') && + str_contains((string) $request->getBody(), 'John'); +}); - public static function validationErrorResponse(array $errors): \Fetch\Http\Response - { - return ClientHandler::createJsonResponse( - [ - 'message' => 'Validation failed', - 'errors' => $errors - ], - Status::UNPROCESSABLE_ENTITY - ); - } -} +// Assert specific number of times +MockServer::assertSent('https://api.example.com/users', 1); +``` -// Usage in tests -class UserServiceTest extends \PHPUnit\Framework\TestCase -{ - public function testGetUser(): void - { - $mockResponses = [ - ResponseFactory::userResponse(1, 'John Doe', 'john@example.com') - ]; +### Assert Request Was Not Sent - // Create client and test... - } +```php +MockServer::assertNotSent('https://api.example.com/posts'); - public function testValidationError(): void - { - $mockResponses = [ - ResponseFactory::validationErrorResponse([ - 'email' => ['The email must be a valid email address.'] - ]) - ]; +MockServer::assertNotSent(function ($request) { + return $request->getMethod() === 'DELETE'; +}); +``` - // Create client and test... - } -} +### Assert Request Count + +```php +MockServer::assertSentCount(3); // Exactly 3 requests +MockServer::assertNothingSent(); // No requests at all ``` -## Testing HTTP Error Handling +## Request Recording -Test how your code handles various HTTP errors: +Record real or mocked requests and replay them later: ```php -use Fetch\Exceptions\NetworkException; +use Fetch\Testing\Recorder; -class ErrorHandlingTest extends \PHPUnit\Framework\TestCase -{ - public function testHandles404Gracefully(): void - { - $mock = new \GuzzleHttp\Handler\MockHandler([ - new \GuzzleHttp\Psr7\Response(404, [], '{"error": "Not found"}') - ]); +// Start recording +Recorder::start(); - $stack = HandlerStack::create($mock); - $guzzleClient = new Client(['handler' => $stack]); - $client = ClientHandler::createWithClient($guzzleClient); +// Make some requests +$response1 = get('https://api.example.com/users'); +$response2 = post('https://api.example.com/users', ['name' => 'Jane']); - $userService = new UserService($client); +// Stop recording +$recordings = Recorder::stop(); - try { - $userService->getUser(999); - $this->fail('Expected exception was not thrown'); - } catch (\RuntimeException $e) { - $this->assertEquals('User 999 not found', $e->getMessage()); - } - } +// Later, replay the recordings +Recorder::replay($recordings); - public function testHandlesNetworkError(): void - { - $mock = new \GuzzleHttp\Handler\MockHandler([ - new \GuzzleHttp\Exception\ConnectException( - 'Connection refused', - new \GuzzleHttp\Psr7\Request('GET', 'https://api.example.com/users/1') - ) - ]); +// Now the same requests will return the recorded responses +$response = get('https://api.example.com/users'); // Returns recorded response +``` - $stack = HandlerStack::create($mock); - $guzzleClient = new Client(['handler' => $stack]); - $client = ClientHandler::createWithClient($guzzleClient); +### Export and Import Recordings - $userService = new UserService($client); +```php +// Export to JSON for storage +Recorder::start(); +get('https://api.example.com/users'); +$json = Recorder::exportToJson(); - $this->expectException(\RuntimeException::class); - $userService->getUser(1); - } -} +// Save to file +file_put_contents('tests/fixtures/recordings.json', $json); + +// Later, load and replay +$json = file_get_contents('tests/fixtures/recordings.json'); +Recorder::importFromJson($json); ``` -## Testing with Retry Logic +## Preventing Stray Requests -Testing how your code handles retry logic: +Ensure all requests are mocked in tests: ```php -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Client; -use Fetch\Http\ClientHandler; -use Fetch\Enum\Status; - -class RetryTest extends \PHPUnit\Framework\TestCase -{ - public function testRetriesOnServerError(): void - { - // Mock responses: first two are 503, last one is 200 - $mock = new MockHandler([ - new Response(503, [], '{"error": "Service Unavailable"}'), - new Response(503, [], '{"error": "Service Unavailable"}'), - new Response(200, [], '{"id": 1, "name": "Success after retry"}') - ]); +MockServer::fake([ + 'https://api.example.com/*' => MockResponse::ok(), +]); - $container = []; - $history = \GuzzleHttp\Middleware::history($container); +MockServer::preventStrayRequests(); - $stack = HandlerStack::create($mock); - $stack->push($history); +get('https://api.example.com/users'); // OK - matches pattern - $guzzleClient = new Client(['handler' => $stack]); - $client = ClientHandler::createWithClient($guzzleClient); +get('https://other-api.com/data'); // Throws InvalidArgumentException +``` - // Configure retry - $client->retry(2, 10) // 2 retries, 10ms delay - ->retryStatusCodes([Status::SERVICE_UNAVAILABLE->value]); +Allow specific URLs: - // Make the request that should auto-retry - $response = $client->get('https://api.example.com/flaky'); +```php +MockServer::fake([ + 'https://api.example.com/*' => MockResponse::ok(), +]); - // Should have 3 requests in history (initial + 2 retries) - $this->assertCount(3, $container); +MockServer::allowStrayRequests([ + 'https://localhost/*', + 'http://127.0.0.1:*', +]); - // Final response should be success - $this->assertEquals(200, $response->status()); - $this->assertEquals('Success after retry', $response->json()['name']); - } -} +get('https://api.example.com/users'); // Mocked +get('https://localhost/test'); // Allowed (real request) ``` -## Testing with Logging +## Testing a Service Class -Testing that appropriate logging occurs: +Here's a complete example of testing a service class: ```php -use Monolog\Logger; -use Monolog\Handler\TestHandler; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Client; -use Fetch\Http\ClientHandler; - -class LoggingTest extends \PHPUnit\Framework\TestCase +use PHPUnit\Framework\TestCase; +use Fetch\Testing\MockServer; +use Fetch\Testing\MockResponse; + +class UserService { - public function testRequestsAreLogged(): void + public function getAllUsers(): array { - // Create a test logger - $testHandler = new TestHandler(); - $logger = new Logger('test'); - $logger->pushHandler($testHandler); - - // Set up mock responses - $mock = new MockHandler([ - new Response(200, [], '{"status": "success"}') - ]); + $response = get('https://api.example.com/users'); + return $response->json()['users']; + } + + public function createUser(array $userData): array + { + $response = post('https://api.example.com/users', $userData); + + if (!$response->successful()) { + throw new \RuntimeException("Failed to create user: " . $response->status()); + } - $stack = HandlerStack::create($mock); - $guzzleClient = new Client(['handler' => $stack]); - $client = ClientHandler::createWithClient($guzzleClient); + return $response->json(); + } - // Set the logger - $client->setLogger($logger); + public function getUser(int $id): array + { + $response = get("https://api.example.com/users/{$id}"); - // Make a request - $client->get('https://api.example.com/test'); + if ($response->isNotFound()) { + throw new \RuntimeException("User {$id} not found"); + } - // Verify logs were created - $this->assertTrue($testHandler->hasInfoThatContains('Sending HTTP request')); - $this->assertTrue($testHandler->hasDebugThatContains('Received HTTP response')); + return $response->json(); } } -``` -## Integration Tests with Real APIs +class UserServiceTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + MockServer::fake([ + 'GET https://api.example.com/users' => MockResponse::json([ + 'users' => [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ] + ]), + 'POST https://api.example.com/users' => MockResponse::created([ + 'id' => 3, + 'name' => 'Bob', + ]), + 'GET https://api.example.com/users/*' => MockResponse::json([ + 'id' => 1, + 'name' => 'John', + ]), + ]); + } -Sometimes you'll want to run integration tests against real APIs. This should typically be done in a separate test suite that can be opted into: + protected function tearDown(): void + { + MockServer::resetInstance(); + parent::tearDown(); + } -```php -/** - * @group integration - */ -class GithubApiIntegrationTest extends \PHPUnit\Framework\TestCase -{ - private \Fetch\Http\ClientHandler $client; + public function test_gets_all_users(): void + { + $service = new UserService(); + $users = $service->getAllUsers(); - protected function setUp(): void + $this->assertCount(2, $users); + MockServer::assertSent('GET https://api.example.com/users'); + } + + public function test_creates_user(): void { - // Skip if no API token is configured - if (empty(getenv('GITHUB_API_TOKEN'))) { - $this->markTestSkipped('No GitHub API token available'); - } + $service = new UserService(); + $user = $service->createUser(['name' => 'Bob']); - $this->client = \Fetch\Http\ClientHandler::createWithBaseUri('https://api.github.com') - ->withToken(getenv('GITHUB_API_TOKEN')) - ->withHeaders([ - 'Accept' => 'application/vnd.github.v3+json', - 'User-Agent' => 'ApiTests' - ]); + $this->assertEquals(3, $user['id']); + + MockServer::assertSent(function ($request) { + $body = json_decode((string) $request->getBody(), true); + return $body['name'] === 'Bob'; + }); } - public function testCanFetchUserProfile(): void + public function test_handles_not_found(): void { - $response = $this->client->get('/user'); + MockServer::fake([ + 'GET https://api.example.com/users/999' => MockResponse::notFound(), + ]); - $this->assertTrue($response->successful()); - $this->assertEquals(200, $response->status()); + $service = new UserService(); - $user = $response->json(); - $this->assertArrayHasKey('login', $user); - $this->assertArrayHasKey('id', $user); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('User 999 not found'); + + $service->getUser(999); } } ``` -## Using Test Doubles +## Testing Retry Logic -You can create test doubles (stubs, mocks) for your service classes: +Test how your code handles retry scenarios: ```php -interface UserRepositoryInterface +public function test_retries_on_failure(): void { - public function find(int $id): ?array; - public function create(array $data): array; + MockServer::fake([ + 'https://api.example.com/unstable' => MockResponse::sequence() + ->pushStatus(503) // Service unavailable + ->pushStatus(503) // Service unavailable + ->pushJson(['success' => true], 200), // Success + ]); + + $response = retry(function () { + return get('https://api.example.com/unstable'); + }, 3, 100); + + $this->assertTrue($response->successful()); + $this->assertEquals(['success' => true], $response->json()); + + // Verify it was called 3 times + MockServer::assertSent('https://api.example.com/unstable', 3); } +``` -class ApiUserRepository implements UserRepositoryInterface -{ - private \Fetch\Http\ClientHandler $client; - - public function __construct(\Fetch\Http\ClientHandler $client) - { - $this->client = $client; - } +## Testing Authentication - public function find(int $id): ?array - { - $response = $this->client->get("/users/{$id}"); +Test authentication requirements: - if ($response->isNotFound()) { - return null; +```php +public function test_requires_authentication(): void +{ + MockServer::fake(function ($request) { + if ($request->hasHeader('Authorization')) { + return MockResponse::json(['data' => 'protected']); } + return MockResponse::unauthorized(['error' => 'Missing token']); + }); + + // Without auth + $response = get('https://api.example.com/protected'); + $this->assertFalse($response->successful()); + $this->assertEquals(401, $response->status()); + + // With auth + $response = fetch('https://api.example.com/protected', [ + 'headers' => ['Authorization' => 'Bearer token'], + ]); + $this->assertTrue($response->successful()); + $this->assertEquals(['data' => 'protected'], $response->json()); +} +``` - return $response->json(); - } +## Testing Error Handling - public function create(array $data): array - { - $response = $this->client->post('/users', $data); +Test various error scenarios: - if (!$response->successful()) { - throw new \RuntimeException("Failed to create user: " . $response->status()); - } +```php +public function test_handles_network_errors(): void +{ + MockServer::fake([ + 'https://api.example.com/error' => MockResponse::ok()->throw( + new \RuntimeException('Connection timeout') + ), + ]); - return $response->json(); - } + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection timeout'); + + get('https://api.example.com/error'); } -class UserServiceTest extends \PHPUnit\Framework\TestCase +public function test_handles_server_errors(): void { - public function testCreateUserCallsRepository(): void - { - // Create a mock repository - $repository = $this->createMock(UserRepositoryInterface::class); + MockServer::fake([ + 'https://api.example.com/server-error' => MockResponse::serverError( + json_encode(['error' => 'Database connection failed']) + ), + ]); - // Set up expectations - $userData = ['name' => 'Test User', 'email' => 'test@example.com']; - $createdUser = array_merge(['id' => 123], $userData); + $response = get('https://api.example.com/server-error'); - $repository->expects($this->once()) - ->method('create') - ->with($userData) - ->willReturn($createdUser); + $this->assertEquals(500, $response->status()); + $this->assertFalse($response->successful()); +} +``` - // Use the mock in our service - $userService = new UserService($repository); - $result = $userService->createUser($userData); +## Best Practices - $this->assertEquals($createdUser, $result); - } -} +1. **Reset MockServer in tearDown**: Always reset the MockServer instance in your test's `tearDown()` method: -class UserService +```php +protected function tearDown(): void { - private UserRepositoryInterface $repository; + MockServer::resetInstance(); + parent::tearDown(); +} +``` - public function __construct(UserRepositoryInterface $repository) - { - $this->repository = $repository; - } +2. **Use specific patterns**: Prefer specific URL patterns over wildcards for better test clarity: - public function createUser(array $userData): array - { - // Validate data, process business logic, etc. +```php +// Good +MockServer::fake([ + 'POST https://api.example.com/users' => MockResponse::created(), +]); - return $this->repository->create($userData); - } -} +// Less specific +MockServer::fake([ + '*' => MockResponse::ok(), +]); ``` -## Best Practices +3. **Test edge cases**: Use sequences to test retry logic, rate limiting, and error recovery: -1. **Mock External Services**: Always mock external API calls in unit tests. +```php +MockResponse::sequence() + ->pushStatus(429) // Rate limited + ->pushStatus(429) // Still rate limited + ->pushStatus(200); // Success after retry +``` -2. **Test Various Response Types**: Test how your code handles success, client errors, server errors, and network issues. +4. **Verify request details**: Use assertion callbacks to verify request payloads, headers, and other details: -3. **Use Status Enums**: Use the type-safe Status enums for clear and maintainable tests. +```php +MockServer::assertSent(function ($request) { + $body = json_decode((string) $request->getBody(), true); + return isset($body['required_field']) && + $request->hasHeader('Content-Type'); +}); +``` -4. **Use Test Data Factories**: Create factories for generating test data consistently. +5. **Prevent stray requests in CI**: Use `preventStrayRequests()` in CI environments: -5. **Separate Integration Tests**: Keep integration tests that hit real APIs separate from unit tests. +```php +if (getenv('CI')) { + MockServer::preventStrayRequests(); +} +``` -6. **Test Asynchronous Code**: If you're using async features, test both the modern async/await and traditional promise patterns. +6. **Keep test data in fixtures**: Store recorded requests/responses in JSON fixtures for reuse: -7. **Verify Request Parameters**: Use history middleware to verify that requests are made with the expected parameters. +```php +$json = file_get_contents(__DIR__ . '/fixtures/user-api-responses.json'); +Recorder::importFromJson($json); +``` -8. **Abstract HTTP Logic**: Use the repository pattern to abstract HTTP logic, making it easier to mock for tests. +## Integration Tests -9. **Test Response Parsing**: Test that your code correctly handles and parses various response formats. +For integration tests that need to hit real APIs: -10. **Test Retry Logic**: Test that your retry configuration works correctly for retryable errors. +```php +/** + * @group integration + */ +class GithubApiIntegrationTest extends TestCase +{ + protected function setUp(): void + { + // Skip if no API token is configured + if (empty(getenv('GITHUB_API_TOKEN'))) { + $this->markTestSkipped('No GitHub API token available'); + } + } + + public function test_can_fetch_user_profile(): void + { + $response = fetch('https://api.github.com/user', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . getenv('GITHUB_API_TOKEN'), + 'Accept' => 'application/vnd.github.v3+json', + ], + ]); -11. **Test Logging**: Verify that appropriate logging occurs for requests and responses. + $this->assertTrue($response->successful()); + $this->assertEquals(200, $response->status()); + + $user = $response->json(); + $this->assertArrayHasKey('login', $user); + } +} +``` ## Next Steps -- Explore [Dependency Injection](/guide/custom-clients#dependency-injection-with-clients) for more testable code - Learn about [Error Handling](/guide/error-handling) for robust applications -- See [Asynchronous Requests](/guide/async-requests) for more on async testing patterns +- Explore [Retry Handling](/guide/retry-handling) for resilient HTTP requests +- See [Asynchronous Requests](/guide/async-requests) for async testing patterns diff --git a/src/Fetch/Concerns/HandlesMocking.php b/src/Fetch/Concerns/HandlesMocking.php new file mode 100644 index 0000000..4659799 --- /dev/null +++ b/src/Fetch/Concerns/HandlesMocking.php @@ -0,0 +1,87 @@ + $options The request options + * @return ResponseInterface|null The mock response or null if not mocked + */ + protected function handleMockRequest(string $method, string $uri, array $options): ?ResponseInterface + { + // Create a PSR-7 request for the mock server + $psrRequest = $this->createPsrRequest($method, $uri, $options); + + // Wrap it in our Request class + $request = Request::createFromBase($psrRequest); + + // Check if MockServer has a response for this request + try { + $mockResponse = MockServer::getInstance()->handleRequest($request); + + if ($mockResponse !== null) { + // Record the request/response if recording is enabled + if (Recorder::isRecording()) { + Recorder::record($request, $mockResponse); + } + + return $mockResponse; + } + } catch (\InvalidArgumentException $e) { + // Re-throw if it's a stray request prevention error + throw $e; + } + + return null; + } + + /** + * Create a PSR-7 request from the given parameters. + * + * @param string $method The HTTP method + * @param string $uri The full URI + * @param array $options The request options + */ + protected function createPsrRequest(string $method, string $uri, array $options): \Psr\Http\Message\RequestInterface + { + $headers = $options['headers'] ?? []; + $body = null; + + // Handle different body types + if (isset($options['json'])) { + $body = Utils::streamFor(json_encode($options['json'])); + $headers['Content-Type'] = 'application/json'; + } elseif (isset($options['form_params'])) { + $body = Utils::streamFor(http_build_query($options['form_params'])); + $headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } elseif (isset($options['body'])) { + $body = Utils::streamFor($options['body']); + } elseif (isset($options['multipart'])) { + // For multipart, we'll create a simple representation + // In a real scenario, Guzzle handles the complex multipart encoding + $body = Utils::streamFor(''); // Empty for now, as multipart is complex + } + + // Append query parameters to URI if present + if (isset($options['query']) && is_array($options['query'])) { + $separator = strpos($uri, '?') !== false ? '&' : '?'; + $uri .= $separator.http_build_query($options['query']); + } + + return new GuzzleRequest($method, $uri, $headers, $body); + } +} diff --git a/src/Fetch/Concerns/ManagesPromises.php b/src/Fetch/Concerns/ManagesPromises.php index 3fc6f41..e125543 100644 --- a/src/Fetch/Concerns/ManagesPromises.php +++ b/src/Fetch/Concerns/ManagesPromises.php @@ -11,6 +11,15 @@ use RuntimeException; use Throwable; +use function Matrix\Support\all; +use function Matrix\Support\any; +use function Matrix\Support\async; +use function Matrix\Support\await; +use function Matrix\Support\race; +use function Matrix\Support\reject; +use function Matrix\Support\resolve; +use function Matrix\Support\timeout; + trait ManagesPromises { /** diff --git a/src/Fetch/Concerns/PerformsHttpRequests.php b/src/Fetch/Concerns/PerformsHttpRequests.php index d84e3e6..4f277f7 100644 --- a/src/Fetch/Concerns/PerformsHttpRequests.php +++ b/src/Fetch/Concerns/PerformsHttpRequests.php @@ -15,7 +15,7 @@ use Matrix\Exceptions\AsyncException; use React\Promise\PromiseInterface; -use function async; +use function Matrix\Support\async; trait PerformsHttpRequests { @@ -30,7 +30,7 @@ trait PerformsHttpRequests public static function handle( string $method, string $uri, - array $options = [] + array $options = [], ): Response|PromiseInterface { $handler = static::create(); $handler->withOptions($options); @@ -77,7 +77,7 @@ public function get(string $uri, array $queryParams = []): ResponseInterface|Pro public function post( string $uri, mixed $body = null, - ContentType|string $contentType = ContentType::JSON + ContentType|string $contentType = ContentType::JSON, ): ResponseInterface|PromiseInterface { return $this->sendRequestWithBody(Method::POST, $uri, $body, $contentType); } @@ -93,7 +93,7 @@ public function post( public function put( string $uri, mixed $body = null, - ContentType|string $contentType = ContentType::JSON + ContentType|string $contentType = ContentType::JSON, ): ResponseInterface|PromiseInterface { return $this->sendRequestWithBody(Method::PUT, $uri, $body, $contentType); } @@ -109,7 +109,7 @@ public function put( public function patch( string $uri, mixed $body = null, - ContentType|string $contentType = ContentType::JSON + ContentType|string $contentType = ContentType::JSON, ): ResponseInterface|PromiseInterface { return $this->sendRequestWithBody(Method::PATCH, $uri, $body, $contentType); } @@ -125,7 +125,7 @@ public function patch( public function delete( string $uri, mixed $body = null, - ContentType|string $contentType = ContentType::JSON + ContentType|string $contentType = ContentType::JSON, ): ResponseInterface|PromiseInterface { return $this->sendRequestWithBody(Method::DELETE, $uri, $body, $contentType); } @@ -141,14 +141,6 @@ public function options(string $uri): ResponseInterface|PromiseInterface return $this->sendRequest(Method::OPTIONS, $uri); } - /** - * Send an HTTP request. - * - * @param Method|string $method The HTTP method - * @param string $uri The URI to request - * @param array $options Additional options - * @return ResponseInterface|PromiseInterface The response or promise - */ /** * Send an HTTP request. * @@ -160,7 +152,7 @@ public function options(string $uri): ResponseInterface|PromiseInterface public function sendRequest( Method|string $method, string $uri, - array $options = [] + array $options = [], ): ResponseInterface|PromiseInterface { // Create a new handler with the combined options $handler = clone $this; @@ -179,6 +171,14 @@ public function sendRequest( // Prepare Guzzle options $guzzleOptions = $handler->prepareGuzzleOptions(); + // Check for mock response first (if HandlesMocking trait is available) + if (method_exists($handler, 'handleMockRequest')) { + $mockResponse = $handler->handleMockRequest($methodStr, $fullUri, $guzzleOptions); + if ($mockResponse !== null) { + return $mockResponse; + } + } + // Start timing for logging $startTime = microtime(true); @@ -210,7 +210,7 @@ public function request( string $uri, mixed $body = null, string|ContentType $contentType = ContentType::JSON, - array $options = [] + array $options = [], ): Response|PromiseInterface { // Normalize method to string $methodStr = $method instanceof Method ? $method->value : strtoupper($method); @@ -265,7 +265,7 @@ protected function sendRequestWithBody( string $uri, mixed $body = null, ContentType|string $contentType = ContentType::JSON, - array $options = [] + array $options = [], ): ResponseInterface|PromiseInterface { // Skip if no body if ($body === null) { @@ -342,7 +342,7 @@ protected function executeSyncRequest( string $method, string $uri, array $options, - float $startTime + float $startTime, ): ResponseInterface { return $this->retryRequest(function () use ($method, $uri, $options, $startTime): ResponseInterface { try { @@ -359,11 +359,7 @@ protected function executeSyncRequest( if (in_array($response->getStatusCode(), $this->getRetryableStatusCodes(), true)) { $psrRequest = new GuzzleRequest($method, $uri, $options['headers'] ?? []); - throw new FetchRequestException( - 'Retryable status: '.$response->getStatusCode(), - $psrRequest, - $psrResponse - ); + throw new FetchRequestException('Retryable status: '.$response->getStatusCode(), $psrRequest, $psrResponse); } // Log response if method exists @@ -378,23 +374,13 @@ protected function executeSyncRequest( $req = $e->getRequest(); $res = $e->getResponse(); - throw new FetchRequestException( - sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), - $req, - $res, - $e - ); + throw new FetchRequestException(sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), $req, $res, $e); } // Fallback when we don't get a Guzzle RequestException (no request available) $psrRequest = new GuzzleRequest($method, $uri, $options['headers'] ?? []); - throw new FetchRequestException( - sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), - $psrRequest, - null, - $e - ); + throw new FetchRequestException(sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), $psrRequest, null, $e); } }); } @@ -410,7 +396,7 @@ protected function executeSyncRequest( protected function executeAsyncRequest( string $method, string $uri, - array $options + array $options, ): PromiseInterface { return async(function () use ($method, $uri, $options): ResponseInterface { $startTime = microtime(true); @@ -436,11 +422,7 @@ protected function executeAsyncRequest( $contextMessage = "Request $method $uri failed"; // Throw the exception - in the async context, this will properly reject the promise - throw new AsyncException( - $contextMessage.': '.$e->getMessage(), - $e->getCode(), - $e // Preserve the original exception as previous - ); + throw new AsyncException($contextMessage.': '.$e->getMessage(), $e->getCode(), $e /* Preserve the original exception as previous */); } }); } diff --git a/src/Fetch/Http/ClientHandler.php b/src/Fetch/Http/ClientHandler.php index 2d0e129..a2a75cb 100644 --- a/src/Fetch/Http/ClientHandler.php +++ b/src/Fetch/Http/ClientHandler.php @@ -5,6 +5,7 @@ namespace Fetch\Http; use Fetch\Concerns\ConfiguresRequests; +use Fetch\Concerns\HandlesMocking; use Fetch\Concerns\HandlesUris; use Fetch\Concerns\ManagesPromises; use Fetch\Concerns\ManagesRetries; @@ -25,6 +26,7 @@ class ClientHandler implements ClientHandlerInterface { use ConfiguresRequests, + HandlesMocking, HandlesUris, ManagesPromises, ManagesRetries, diff --git a/src/Fetch/Http/Request.php b/src/Fetch/Http/Request.php index 441a67b..c3e7a2d 100644 --- a/src/Fetch/Http/Request.php +++ b/src/Fetch/Http/Request.php @@ -275,6 +275,20 @@ public static function options(string|UriInterface $uri, array $headers = []): s return new static(Method::OPTIONS->value, $uri, $headers); } + /** + * Create a new Request instance from a PSR-7 request. + */ + public static function createFromBase(RequestInterface $request): static + { + return new static( + $request->getMethod(), + $request->getUri(), + $request->getHeaders(), + $request->getBody(), + $request->getProtocolVersion() + ); + } + /** * Override getRequestTarget to use our custom target if set. */ diff --git a/src/Fetch/Testing/MockResponse.php b/src/Fetch/Testing/MockResponse.php new file mode 100644 index 0000000..46699c4 --- /dev/null +++ b/src/Fetch/Testing/MockResponse.php @@ -0,0 +1,280 @@ +> + */ + protected array $headers; + + /** + * Delay before returning response (milliseconds). + */ + protected int $delay = 0; + + /** + * Exception to throw instead of returning response. + */ + protected ?Throwable $throwable = null; + + /** + * Create a new mock response instance. + * + * @param int $status HTTP status code + * @param mixed $body Response body + * @param array> $headers Response headers + */ + public function __construct(int $status = 200, mixed $body = '', array $headers = []) + { + $this->status = $status; + $this->body = $body; + $this->headers = $headers; + } + + /** + * Create a new mock response instance. + * + * @param int $status HTTP status code + * @param mixed $body Response body + * @param array> $headers Response headers + */ + public static function create(int $status = 200, mixed $body = '', array $headers = []): self + { + return new self($status, $body, $headers); + } + + /** + * Create a JSON response. + * + * @param array|object $data Data to encode as JSON + * @param int $status HTTP status code + * @param array> $headers Additional headers + */ + public static function json(array|object $data, int $status = 200, array $headers = []): self + { + $headers['Content-Type'] = 'application/json'; + + return new self($status, json_encode($data), $headers); + } + + /** + * Create a sequence of mock responses. + * + * @param array $responses Array of MockResponse instances + */ + public static function sequence(array $responses = []): MockResponseSequence + { + return new MockResponseSequence($responses); + } + + /** + * Create a response with a specific HTTP status. + * + * @param array> $headers + */ + public static function ok(mixed $body = '', array $headers = []): self + { + return new self(Status::OK->value, $body, $headers); + } + + /** + * Create a 201 Created response. + * + * @param array> $headers + */ + public static function created(mixed $body = '', array $headers = []): self + { + return new self(Status::CREATED->value, $body, $headers); + } + + /** + * Create a 204 No Content response. + * + * @param array> $headers + */ + public static function noContent(array $headers = []): self + { + return new self(Status::NO_CONTENT->value, '', $headers); + } + + /** + * Create a 400 Bad Request response. + * + * @param array> $headers + */ + public static function badRequest(mixed $body = '', array $headers = []): self + { + return new self(Status::BAD_REQUEST->value, $body, $headers); + } + + /** + * Create a 401 Unauthorized response. + * + * @param array> $headers + */ + public static function unauthorized(mixed $body = '', array $headers = []): self + { + return new self(Status::UNAUTHORIZED->value, $body, $headers); + } + + /** + * Create a 403 Forbidden response. + * + * @param array> $headers + */ + public static function forbidden(mixed $body = '', array $headers = []): self + { + return new self(Status::FORBIDDEN->value, $body, $headers); + } + + /** + * Create a 404 Not Found response. + * + * @param array> $headers + */ + public static function notFound(mixed $body = '', array $headers = []): self + { + return new self(Status::NOT_FOUND->value, $body, $headers); + } + + /** + * Create a 422 Unprocessable Entity response. + * + * @param array> $headers + */ + public static function unprocessableEntity(mixed $body = '', array $headers = []): self + { + return new self(Status::UNPROCESSABLE_ENTITY->value, $body, $headers); + } + + /** + * Create a 500 Internal Server Error response. + * + * @param array> $headers + */ + public static function serverError(mixed $body = '', array $headers = []): self + { + return new self(Status::INTERNAL_SERVER_ERROR->value, $body, $headers); + } + + /** + * Create a 503 Service Unavailable response. + * + * @param array> $headers + */ + public static function serviceUnavailable(mixed $body = '', array $headers = []): self + { + return new self(Status::SERVICE_UNAVAILABLE->value, $body, $headers); + } + + /** + * Set a delay before returning the response. + * + * @param int $milliseconds Delay in milliseconds + */ + public function delay(int $milliseconds): self + { + $this->delay = $milliseconds; + + return $this; + } + + /** + * Throw an exception instead of returning a response. + */ + public function throw(Throwable $throwable): self + { + $this->throwable = $throwable; + + return $this; + } + + /** + * Get the response status code. + */ + public function getStatus(): int + { + return $this->status; + } + + /** + * Get the response body. + */ + public function getBody(): mixed + { + return $this->body; + } + + /** + * Get the response headers. + * + * @return array> + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get the delay in milliseconds. + */ + public function getDelay(): int + { + return $this->delay; + } + + /** + * Get the throwable exception if set. + */ + public function getThrowable(): ?Throwable + { + return $this->throwable; + } + + /** + * Execute the mock response (apply delay, throw exception, or return response). + * + * @throws Throwable + */ + public function execute(): ResponseInterface + { + // Apply delay if set + if ($this->delay > 0) { + usleep($this->delay * 1000); + } + + // Throw exception if set + if ($this->throwable !== null) { + throw $this->throwable; + } + + // Convert body to string if it's not already + $body = is_string($this->body) ? $this->body : json_encode($this->body); + + return Response::createFromBase( + new GuzzleResponse($this->status, $this->headers, $body) + ); + } +} diff --git a/src/Fetch/Testing/MockResponseSequence.php b/src/Fetch/Testing/MockResponseSequence.php new file mode 100644 index 0000000..7d86f09 --- /dev/null +++ b/src/Fetch/Testing/MockResponseSequence.php @@ -0,0 +1,187 @@ + + */ + protected array $responses = []; + + /** + * The current index in the sequence. + */ + protected int $currentIndex = 0; + + /** + * The default response to use when the sequence is empty. + */ + protected ?MockResponse $defaultResponse = null; + + /** + * Whether the sequence should loop. + */ + protected bool $shouldLoop = false; + + /** + * Create a new mock response sequence. + * + * @param array $responses Initial responses + */ + public function __construct(array $responses = []) + { + $this->responses = $responses; + } + + /** + * Add a response to the sequence. + * + * @param int $status HTTP status code + * @param mixed $body Response body + * @param array> $headers Response headers + */ + public function push(int $status = 200, mixed $body = '', array $headers = []): self + { + $this->responses[] = MockResponse::create($status, $body, $headers); + + return $this; + } + + /** + * Add a JSON response to the sequence. + * + * @param array|object $data Data to encode as JSON + * @param int $status HTTP status code + * @param array> $headers Additional headers + */ + public function pushJson(array|object $data, int $status = 200, array $headers = []): self + { + $this->responses[] = MockResponse::json($data, $status, $headers); + + return $this; + } + + /** + * Add a status-only response to the sequence. + * + * @param array> $headers + */ + public function pushStatus(int $status, array $headers = []): self + { + $this->responses[] = MockResponse::create($status, '', $headers); + + return $this; + } + + /** + * Add a response instance to the sequence. + */ + public function pushResponse(MockResponse $response): self + { + $this->responses[] = $response; + + return $this; + } + + /** + * Set the default response to use when the sequence is exhausted. + */ + public function whenEmpty(MockResponse $response): self + { + $this->defaultResponse = $response; + + return $this; + } + + /** + * Make the sequence loop back to the beginning when exhausted. + */ + public function loop(): self + { + $this->shouldLoop = true; + + return $this; + } + + /** + * Get the next response in the sequence. + * + * @throws OutOfBoundsException + */ + public function next(): MockResponse + { + if (empty($this->responses)) { + if ($this->defaultResponse !== null) { + return $this->defaultResponse; + } + + throw new OutOfBoundsException('No more responses in the sequence.'); + } + + if ($this->currentIndex >= count($this->responses)) { + if ($this->shouldLoop) { + $this->currentIndex = 0; + } elseif ($this->defaultResponse !== null) { + return $this->defaultResponse; + } else { + throw new OutOfBoundsException('No more responses in the sequence.'); + } + } + + $response = $this->responses[$this->currentIndex]; + $this->currentIndex++; + + return $response; + } + + /** + * Check if there are more responses in the sequence. + */ + public function hasMore(): bool + { + return $this->currentIndex < count($this->responses) + || $this->shouldLoop + || $this->defaultResponse !== null; + } + + /** + * Reset the sequence to the beginning. + */ + public function reset(): self + { + $this->currentIndex = 0; + + return $this; + } + + /** + * Get the current index in the sequence. + */ + public function getCurrentIndex(): int + { + return $this->currentIndex; + } + + /** + * Get the total number of responses in the sequence. + */ + public function count(): int + { + return count($this->responses); + } + + /** + * Check if the sequence is empty. + */ + public function isEmpty(): bool + { + return empty($this->responses); + } +} diff --git a/src/Fetch/Testing/MockServer.php b/src/Fetch/Testing/MockServer.php new file mode 100644 index 0000000..8b421f8 --- /dev/null +++ b/src/Fetch/Testing/MockServer.php @@ -0,0 +1,414 @@ + + */ + protected array $fakes = []; + + /** + * The callback to use for dynamic response matching. + */ + protected ?Closure $callback = null; + + /** + * Whether to prevent stray requests. + */ + protected bool $preventStrayRequests = false; + + /** + * Allowed URL patterns for stray requests. + * + * @var array + */ + protected array $allowedStrayPatterns = []; + + /** + * Recorded requests and responses. + * + * @var array + */ + protected array $recorded = []; + + /** + * Whether recording is enabled. + */ + protected bool $recording = false; + + /** + * Get the singleton instance. + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self; + } + + return self::$instance; + } + + /** + * Set up fake responses for specific URL patterns. + * + * @param array|Closure|null $patterns URL patterns and their responses + */ + public static function fake(array|Closure|null $patterns = null): void + { + $instance = self::getInstance(); + $instance->reset(); + + if ($patterns === null) { + // Fake all requests with empty 200 responses + $instance->callback = fn () => MockResponse::ok(); + + return; + } + + if ($patterns instanceof Closure) { + // Use callback for all requests + $instance->callback = $patterns; + + return; + } + + // Register pattern-based fakes + foreach ($patterns as $pattern => $response) { + $instance->fakes[$pattern] = $response; + } + } + + /** + * Prevent requests that don't match any registered fakes. + */ + public static function preventStrayRequests(): void + { + self::getInstance()->preventStrayRequests = true; + } + + /** + * Allow stray requests to specific URL patterns. + * + * @param array $patterns URL patterns to allow + */ + public static function allowStrayRequests(array $patterns = []): void + { + $instance = self::getInstance(); + $instance->preventStrayRequests = false; + $instance->allowedStrayPatterns = $patterns; + } + + /** + * Start recording requests and responses. + */ + public static function startRecording(): void + { + $instance = self::getInstance(); + $instance->recording = true; + $instance->recorded = []; + } + + /** + * Stop recording and return the recorded requests/responses. + * + * @return array + */ + public static function stopRecording(): array + { + $instance = self::getInstance(); + $instance->recording = false; + + return $instance->recorded; + } + + /** + * Get all recorded requests and responses. + * + * @param Closure|null $filter Optional filter callback + * @return array + */ + public static function recorded(?Closure $filter = null): array + { + $instance = self::getInstance(); + $recorded = $instance->recorded; + + if ($filter !== null) { + return array_filter($recorded, $filter); + } + + return $recorded; + } + + /** + * Assert that a request was sent matching the given criteria. + * + * @param string|Closure $pattern URL pattern or callback + * @param int|null $times Expected number of times (null = at least once) + */ + public static function assertSent(string|Closure $pattern, ?int $times = null): void + { + $instance = self::getInstance(); + $matches = []; + + if ($pattern instanceof Closure) { + $matches = array_filter($instance->recorded, function ($record) use ($pattern) { + return $pattern($record['request'], $record['response']); + }); + } else { + $matches = array_filter($instance->recorded, function ($record) use ($pattern, $instance) { + $url = (string) $record['request']->getUri(); + $method = $record['request']->getMethod(); + + return $instance->matchesPattern("{$method} {$url}", $pattern) + || $instance->matchesPattern($url, $pattern); + }); + } + + $count = count($matches); + + if ($times === null) { + PHPUnit::assertTrue( + $count > 0, + 'Expected request was not sent.' + ); + } else { + PHPUnit::assertSame( + $times, + $count, + "Expected request to be sent {$times} time(s), but was sent {$count} time(s)." + ); + } + } + + /** + * Assert that a request was not sent matching the given criteria. + * + * @param string|Closure $pattern URL pattern or callback + */ + public static function assertNotSent(string|Closure $pattern): void + { + $instance = self::getInstance(); + $matches = []; + + if ($pattern instanceof Closure) { + $matches = array_filter($instance->recorded, function ($record) use ($pattern) { + return $pattern($record['request'], $record['response']); + }); + } else { + $matches = array_filter($instance->recorded, function ($record) use ($pattern, $instance) { + $url = (string) $record['request']->getUri(); + $method = $record['request']->getMethod(); + + return $instance->matchesPattern("{$method} {$url}", $pattern) + || $instance->matchesPattern($url, $pattern); + }); + } + + PHPUnit::assertCount( + 0, + $matches, + 'Unexpected request was sent.' + ); + } + + /** + * Assert that exactly N requests were sent. + */ + public static function assertSentCount(int $count): void + { + $instance = self::getInstance(); + + PHPUnit::assertCount( + $count, + $instance->recorded, + "Expected {$count} request(s) to be sent, but ".count($instance->recorded).' were sent.' + ); + } + + /** + * Assert that no requests were sent. + */ + public static function assertNothingSent(): void + { + self::assertSentCount(0); + } + + /** + * Reset the singleton instance completely. + */ + public static function resetInstance(): void + { + self::$instance = null; + } + + /** + * Handle a request and return the mocked response. + * + * @throws InvalidArgumentException + */ + public function handleRequest(Request $request): ?ResponseInterface + { + $url = (string) $request->getUri(); + $method = $request->getMethod(); + + // Try to find a matching fake + $response = $this->findMatchingResponse($request, $url, $method); + + // If no match found + if ($response === null) { + if ($this->preventStrayRequests && ! $this->isAllowedStrayRequest($url)) { + throw new InvalidArgumentException( + "No fake response registered for [{$method} {$url}] and stray requests are prevented." + ); + } + + return null; // Let the real request go through + } + + // Execute the response + $executedResponse = $response instanceof MockResponse + ? $response->execute() + : $response; + + // Record the request/response if recording is enabled + if ($this->recording) { + $this->recorded[] = [ + 'request' => $request, + 'response' => $executedResponse, + ]; + } + + return $executedResponse; + } + + /** + * Reset the mock server state. + */ + public function reset(): void + { + $this->fakes = []; + $this->callback = null; + $this->preventStrayRequests = false; + $this->allowedStrayPatterns = []; + $this->recorded = []; + $this->recording = true; // Auto-enable recording when faking + } + + /** + * Find a matching response for the given request. + */ + protected function findMatchingResponse(Request $request, string $url, string $method): MockResponse|ResponseInterface|null + { + // Try callback first + if ($this->callback !== null) { + $response = ($this->callback)($request); + + return $this->normalizeResponse($response); + } + + // Try exact pattern matches with method + $fullPattern = "{$method} {$url}"; + if (isset($this->fakes[$fullPattern])) { + return $this->getResponseFromFake($this->fakes[$fullPattern]); + } + + // Try URL-only patterns + if (isset($this->fakes[$url])) { + return $this->getResponseFromFake($this->fakes[$url]); + } + + // Try wildcard pattern matches with method + foreach ($this->fakes as $pattern => $fake) { + if ($this->matchesPattern("{$method} {$url}", $pattern)) { + return $this->getResponseFromFake($fake); + } + } + + // Try URL-only wildcard patterns + foreach ($this->fakes as $pattern => $fake) { + if ($this->matchesPattern($url, $pattern)) { + return $this->getResponseFromFake($fake); + } + } + + return null; + } + + /** + * Get the response from a fake (handles sequences and closures). + */ + protected function getResponseFromFake(mixed $fake): MockResponse|ResponseInterface|null + { + if ($fake instanceof MockResponseSequence) { + return $fake->next(); + } + + if ($fake instanceof Closure) { + $response = $fake(); + + return $this->normalizeResponse($response); + } + + return $fake; + } + + /** + * Normalize a response (convert arrays to JSON responses). + */ + protected function normalizeResponse(mixed $response): MockResponse|ResponseInterface|null + { + if (is_array($response)) { + return MockResponse::json($response); + } + + return $response; + } + + /** + * Check if a URL matches a pattern (supports wildcards). + */ + protected function matchesPattern(string $url, string $pattern): bool + { + // Convert wildcard pattern to regex + $regex = preg_quote($pattern, '/'); + $regex = str_replace('\*', '.*', $regex); + $regex = '/^'.$regex.'$/'; + + return (bool) preg_match($regex, $url); + } + + /** + * Check if a URL is allowed as a stray request. + */ + protected function isAllowedStrayRequest(string $url): bool + { + if (empty($this->allowedStrayPatterns)) { + return false; + } + + foreach ($this->allowedStrayPatterns as $pattern) { + if ($this->matchesPattern($url, $pattern)) { + return true; + } + } + + return false; + } +} diff --git a/src/Fetch/Testing/Recorder.php b/src/Fetch/Testing/Recorder.php new file mode 100644 index 0000000..9431fc3 --- /dev/null +++ b/src/Fetch/Testing/Recorder.php @@ -0,0 +1,236 @@ + + */ + protected array $recordings = []; + + /** + * Get the singleton instance. + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self; + } + + return self::$instance; + } + + /** + * Start recording requests and responses. + */ + public static function start(): void + { + $instance = self::getInstance(); + $instance->isRecording = true; + $instance->recordings = []; + + // Also enable recording in MockServer if it's being used + MockServer::startRecording(); + } + + /** + * Stop recording and return the recordings. + * + * @return array + */ + public static function stop(): array + { + $instance = self::getInstance(); + $instance->isRecording = false; + + return $instance->recordings; + } + + /** + * Record a request and response. + */ + public static function record(Request $request, ResponseInterface $response): void + { + $instance = self::getInstance(); + + if (! $instance->isRecording) { + return; + } + + $instance->recordings[] = [ + 'request' => $request, + 'response' => $response, + 'timestamp' => microtime(true), + ]; + } + + /** + * Check if recording is active. + */ + public static function isRecording(): bool + { + return self::getInstance()->isRecording; + } + + /** + * Get all recordings. + * + * @return array + */ + public static function getRecordings(): array + { + return self::getInstance()->recordings; + } + + /** + * Clear all recordings. + */ + public static function clear(): void + { + self::getInstance()->recordings = []; + } + + /** + * Replay recordings by setting up mock responses. + * + * @param array $recordings Recordings to replay + */ + public static function replay(array $recordings): void + { + $fakes = []; + + foreach ($recordings as $recording) { + $request = $recording['request']; + $response = $recording['response']; + + $url = (string) $request->getUri(); + $method = $request->getMethod(); + $pattern = "{$method} {$url}"; + + // Convert the recorded response to a MockResponse + $mockResponse = MockResponse::create( + $response->status(), + $response->body(), + $response->getHeaders() + ); + + // If the pattern already exists, convert to sequence + if (isset($fakes[$pattern])) { + if (! $fakes[$pattern] instanceof MockResponseSequence) { + $fakes[$pattern] = MockResponse::sequence([$fakes[$pattern]]); + } + $fakes[$pattern]->pushResponse($mockResponse); + } else { + $fakes[$pattern] = $mockResponse; + } + } + + MockServer::fake($fakes); + } + + /** + * Export recordings to JSON. + */ + public static function exportToJson(): string + { + $instance = self::getInstance(); + $exportData = []; + + foreach ($instance->recordings as $recording) { + $request = $recording['request']; + $response = $recording['response']; + + $exportData[] = [ + 'request' => [ + 'method' => $request->getMethod(), + 'url' => (string) $request->getUri(), + 'headers' => $request->getHeaders(), + 'body' => (string) $request->getBody(), + ], + 'response' => [ + 'status' => $response->status(), + 'headers' => $response->getHeaders(), + 'body' => $response->body(), + ], + 'timestamp' => $recording['timestamp'] ?? null, + ]; + } + + return json_encode($exportData, JSON_PRETTY_PRINT); + } + + /** + * Import recordings from JSON and replay them. + */ + public static function importFromJson(string $json): void + { + $data = json_decode($json, true); + + if (! is_array($data)) { + throw new InvalidArgumentException('Invalid JSON format for recordings.'); + } + + $fakes = []; + + foreach ($data as $recording) { + $method = $recording['request']['method'] ?? 'GET'; + $url = $recording['request']['url'] ?? ''; + $pattern = "{$method} {$url}"; + + $mockResponse = MockResponse::create( + $recording['response']['status'] ?? 200, + $recording['response']['body'] ?? '', + $recording['response']['headers'] ?? [] + ); + + // If the pattern already exists, convert to sequence + if (isset($fakes[$pattern])) { + if (! $fakes[$pattern] instanceof MockResponseSequence) { + $fakes[$pattern] = MockResponse::sequence([$fakes[$pattern]]); + } + $fakes[$pattern]->pushResponse($mockResponse); + } else { + $fakes[$pattern] = $mockResponse; + } + } + + MockServer::fake($fakes); + } + + /** + * Reset the recorder instance. + */ + public static function reset(): void + { + $instance = self::getInstance(); + $instance->isRecording = false; + $instance->recordings = []; + } + + /** + * Reset the singleton instance completely. + */ + public static function resetInstance(): void + { + self::$instance = null; + } +} diff --git a/tests/Integration/AsyncRequestsTest.php b/tests/Integration/AsyncRequestsTest.php index 1cf5e94..ac6b60c 100644 --- a/tests/Integration/AsyncRequestsTest.php +++ b/tests/Integration/AsyncRequestsTest.php @@ -10,6 +10,10 @@ use PHPUnit\Framework\TestCase; use React\Promise\PromiseInterface; +use function Matrix\Support\all; +use function Matrix\Support\async; +use function Matrix\Support\await; + class AsyncRequestsTest extends TestCase { private $client; diff --git a/tests/Integration/MockingIntegrationTest.php b/tests/Integration/MockingIntegrationTest.php new file mode 100644 index 0000000..f6bf2b7 --- /dev/null +++ b/tests/Integration/MockingIntegrationTest.php @@ -0,0 +1,306 @@ + MockResponse::json(['users' => ['John', 'Jane']]), + ]); + + $response = get('https://api.example.com/users'); + + $this->assertSame(200, $response->status()); + $this->assertSame(['users' => ['John', 'Jane']], $response->json()); + } + + public function test_mocks_post_request(): void + { + MockServer::fake([ + 'POST https://api.example.com/users' => MockResponse::created(['id' => 123]), + ]); + + $response = post('https://api.example.com/users', ['name' => 'John Doe']); + + $this->assertSame(201, $response->status()); + $this->assertSame(['id' => 123], $response->json()); + } + + public function test_mocks_with_wildcard_pattern(): void + { + MockServer::fake([ + 'https://api.example.com/users/*' => MockResponse::json(['user' => 'found']), + ]); + + $response1 = get('https://api.example.com/users/123'); + $response2 = get('https://api.example.com/users/456'); + + $this->assertSame(['user' => 'found'], $response1->json()); + $this->assertSame(['user' => 'found'], $response2->json()); + } + + public function test_mocks_with_sequence(): void + { + MockServer::fake([ + 'https://api.example.com/flaky' => MockResponse::sequence() + ->pushStatus(500) + ->pushStatus(500) + ->pushStatus(200), + ]); + + $response1 = get('https://api.example.com/flaky'); + $response2 = get('https://api.example.com/flaky'); + $response3 = get('https://api.example.com/flaky'); + + $this->assertSame(500, $response1->status()); + $this->assertSame(500, $response2->status()); + $this->assertSame(200, $response3->status()); + } + + public function test_mocks_with_callback(): void + { + MockServer::fake(function ($request) { + if ($request->hasHeader('Authorization')) { + return MockResponse::json(['authenticated' => true]); + } + + return MockResponse::unauthorized(); + }); + + $authenticatedResponse = fetch('https://api.example.com/protected', [ + 'headers' => ['Authorization' => 'Bearer token'], + ]); + + $unauthenticatedResponse = get('https://api.example.com/protected'); + + $this->assertSame(200, $authenticatedResponse->status()); + $this->assertSame(['authenticated' => true], $authenticatedResponse->json()); + $this->assertSame(401, $unauthenticatedResponse->status()); + } + + public function test_asserts_requests_sent(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + post('https://api.example.com/users', ['name' => 'John']); + get('https://api.example.com/users'); + + MockServer::assertSent('POST https://api.example.com/users'); + MockServer::assertSent('GET https://api.example.com/users'); + MockServer::assertSentCount(2); + } + + public function test_asserts_with_callback(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + post('https://api.example.com/users', ['name' => 'John Doe']); + + MockServer::assertSent(function ($request) { + $body = (string) $request->getBody(); + + return str_contains($body, 'John Doe'); + }); + } + + public function test_asserts_not_sent(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + get('https://api.example.com/users'); + + MockServer::assertNotSent('POST https://api.example.com/users'); + MockServer::assertNotSent('https://api.example.com/posts'); + } + + public function test_prevents_stray_requests(): void + { + MockServer::fake([ + 'https://api.example.com/*' => MockResponse::ok(), + ]); + MockServer::preventStrayRequests(); + + // This should work + $response = get('https://api.example.com/users'); + $this->assertSame(200, $response->status()); + + // This should throw + $this->expectException(\InvalidArgumentException::class); + get('https://other-api.example.com/data'); + } + + public function test_records_and_replays_requests(): void + { + // First, make some "real" requests with mocked responses + MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json(['users' => ['John']]), + 'POST https://api.example.com/users' => MockResponse::created(['id' => 1]), + ]); + + Recorder::start(); + + get('https://api.example.com/users'); + post('https://api.example.com/users', ['name' => 'Jane']); + + $recordings = Recorder::stop(); + + // Verify we recorded 2 requests + $this->assertCount(2, $recordings); + + // Reset the mock server + MockServer::resetInstance(); + + // Replay the recordings + Recorder::replay($recordings); + + // Now make the same requests again + $response1 = get('https://api.example.com/users'); + $response2 = post('https://api.example.com/users', ['name' => 'Jane']); + + $this->assertSame(['users' => ['John']], $response1->json()); + $this->assertSame(201, $response2->status()); + } + + public function test_exports_and_imports_recordings(): void + { + MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json(['users' => []]), + ]); + + Recorder::start(); + get('https://api.example.com/users'); + Recorder::stop(); + + // Export to JSON + $json = Recorder::exportToJson(); + + // Reset everything + MockServer::resetInstance(); + Recorder::resetInstance(); + + // Import from JSON + Recorder::importFromJson($json); + + // Verify it works + $response = get('https://api.example.com/users'); + $this->assertSame(['users' => []], $response->json()); + } + + public function test_fake_with_delay(): void + { + MockServer::fake([ + 'https://api.example.com/slow' => MockResponse::ok('Done')->delay(50), + ]); + + $start = microtime(true); + $response = get('https://api.example.com/slow'); + $duration = (microtime(true) - $start) * 1000; + + $this->assertGreaterThanOrEqual(50, $duration); + $this->assertSame('Done', $response->body()); + } + + public function test_fake_with_exception(): void + { + MockServer::fake([ + 'https://api.example.com/error' => MockResponse::ok()->throw(new \RuntimeException('Network error')), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Network error'); + + get('https://api.example.com/error'); + } + + public function test_multiple_patterns_with_priority(): void + { + MockServer::fake([ + 'https://api.example.com/users/123' => MockResponse::json(['id' => 123, 'specific' => true]), + 'https://api.example.com/users/*' => MockResponse::json(['id' => 999, 'wildcard' => true]), + ]); + + // Exact match should take priority + $response1 = get('https://api.example.com/users/123'); + $this->assertTrue($response1->json()['specific']); + + // Wildcard should match others + $response2 = get('https://api.example.com/users/456'); + $this->assertTrue($response2->json()['wildcard']); + } + + public function test_fetch_with_various_options(): void + { + MockServer::fake([ + '*' => MockResponse::ok('Success'), + ]); + + $response = fetch('https://api.example.com/users', [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => 'Bearer token', + 'Accept' => 'application/json', + ], + 'json' => ['name' => 'John'], + 'timeout' => 30, + ]); + + $this->assertSame(200, $response->status()); + + MockServer::assertSent(function ($request) { + return $request->hasHeader('Authorization') + && $request->hasHeader('Accept') + && $request->getMethod() === 'POST'; + }); + } + + public function test_recorded_requests_include_all_details(): void + { + MockServer::fake([ + '*' => MockResponse::json(['result' => 'ok']), + ]); + + post('https://api.example.com/users', ['name' => 'John'], [ + 'headers' => ['X-Custom' => 'value'], + ]); + + $recorded = MockServer::recorded(); + + $this->assertCount(1, $recorded); + $this->assertSame('POST', $recorded[0]['request']->getMethod()); + $this->assertSame('https://api.example.com/users', (string) $recorded[0]['request']->getUri()); + $this->assertSame(['result' => 'ok'], $recorded[0]['response']->json()); + } +} diff --git a/tests/Unit/ManagesPromisesTest.php b/tests/Unit/ManagesPromisesTest.php index 321116e..d2e137c 100644 --- a/tests/Unit/ManagesPromisesTest.php +++ b/tests/Unit/ManagesPromisesTest.php @@ -10,6 +10,11 @@ use React\Promise\PromiseInterface; use ReflectionClass; +use function Matrix\Support\async; +use function Matrix\Support\await; +use function Matrix\Support\reject; +use function Matrix\Support\resolve; + class ManagesPromisesTest extends TestCase { private $handler; diff --git a/tests/Unit/ManagesRetriesTest.php b/tests/Unit/ManagesRetriesTest.php index 5292fdd..55beba7 100644 --- a/tests/Unit/ManagesRetriesTest.php +++ b/tests/Unit/ManagesRetriesTest.php @@ -2,9 +2,9 @@ namespace Tests\Unit; +use Fetch\Exceptions\RequestException; use Fetch\Http\ClientHandler; use Fetch\Http\Response; -use Fetch\Exceptions\RequestException; use GuzzleHttp\Exception\ConnectException; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; diff --git a/tests/Unit/PerformsHttpRequestsTest.php b/tests/Unit/PerformsHttpRequestsTest.php index a3d7060..4b7e617 100644 --- a/tests/Unit/PerformsHttpRequestsTest.php +++ b/tests/Unit/PerformsHttpRequestsTest.php @@ -4,17 +4,16 @@ use Fetch\Enum\ContentType; use Fetch\Enum\Method; +use Fetch\Exceptions\RequestException as FetchRequestException; use Fetch\Http\ClientHandler; use Fetch\Http\Response; use GuzzleHttp\Client; -use Fetch\Exceptions\RequestException as FetchRequestException; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Request as GuzzleRequest; use GuzzleHttp\Psr7\Response as GuzzleResponse; use PHPUnit\Framework\TestCase; use ReflectionClass; -use RuntimeException; use Tests\Mocks\TestableClientHandler; class PerformsHttpRequestsTest extends TestCase diff --git a/tests/Unit/Testing/MockResponseSequenceTest.php b/tests/Unit/Testing/MockResponseSequenceTest.php new file mode 100644 index 0000000..ca0b3e4 --- /dev/null +++ b/tests/Unit/Testing/MockResponseSequenceTest.php @@ -0,0 +1,224 @@ +assertTrue($sequence->isEmpty()); + $this->assertSame(0, $sequence->count()); + } + + public function test_creates_sequence_with_initial_responses(): void + { + $responses = [ + MockResponse::ok(), + MockResponse::created(), + ]; + + $sequence = new MockResponseSequence($responses); + + $this->assertFalse($sequence->isEmpty()); + $this->assertSame(2, $sequence->count()); + } + + public function test_pushes_response(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->push(201, 'Second'); + + $this->assertSame(2, $sequence->count()); + } + + public function test_pushes_json_response(): void + { + $sequence = new MockResponseSequence; + $sequence->pushJson(['data' => 'value'], 200); + + $response = $sequence->next(); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame(json_encode(['data' => 'value']), $response->getBody()); + } + + public function test_pushes_status_response(): void + { + $sequence = new MockResponseSequence; + $sequence->pushStatus(404); + + $response = $sequence->next(); + + $this->assertSame(404, $response->getStatus()); + $this->assertSame('', $response->getBody()); + } + + public function test_pushes_response_instance(): void + { + $mockResponse = MockResponse::ok('Test'); + $sequence = new MockResponseSequence; + $sequence->pushResponse($mockResponse); + + $response = $sequence->next(); + + $this->assertSame($mockResponse, $response); + } + + public function test_gets_next_response_in_sequence(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->push(201, 'Second'); + $sequence->push(202, 'Third'); + + $first = $sequence->next(); + $this->assertSame(200, $first->getStatus()); + $this->assertSame('First', $first->getBody()); + + $second = $sequence->next(); + $this->assertSame(201, $second->getStatus()); + $this->assertSame('Second', $second->getBody()); + + $third = $sequence->next(); + $this->assertSame(202, $third->getStatus()); + $this->assertSame('Third', $third->getBody()); + } + + public function test_throws_when_sequence_exhausted(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'Only one'); + + $sequence->next(); // Get the first and only response + + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('No more responses in the sequence.'); + + $sequence->next(); + } + + public function test_uses_default_response_when_empty(): void + { + $defaultResponse = MockResponse::ok('Default'); + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->whenEmpty($defaultResponse); + + $sequence->next(); // Get the first response + + // Now sequence is exhausted, should return default + $response = $sequence->next(); + + $this->assertSame($defaultResponse, $response); + } + + public function test_loops_sequence(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->push(201, 'Second'); + $sequence->loop(); + + // Get first round + $first = $sequence->next(); + $second = $sequence->next(); + + // Should loop back to the beginning + $firstAgain = $sequence->next(); + $secondAgain = $sequence->next(); + + $this->assertSame(200, $first->getStatus()); + $this->assertSame(201, $second->getStatus()); + $this->assertSame(200, $firstAgain->getStatus()); + $this->assertSame(201, $secondAgain->getStatus()); + } + + public function test_checks_has_more(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + + $this->assertTrue($sequence->hasMore()); + + $sequence->next(); + + $this->assertFalse($sequence->hasMore()); + } + + public function test_has_more_with_default_response(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->whenEmpty(MockResponse::ok()); + + $sequence->next(); // Exhaust the sequence + + $this->assertTrue($sequence->hasMore()); // Still has more because of default + } + + public function test_has_more_with_loop(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->loop(); + + $sequence->next(); // Would normally exhaust + + $this->assertTrue($sequence->hasMore()); // Still has more because of loop + } + + public function test_resets_sequence(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->push(201, 'Second'); + + $sequence->next(); // Move to index 1 + $this->assertSame(1, $sequence->getCurrentIndex()); + + $sequence->reset(); + $this->assertSame(0, $sequence->getCurrentIndex()); + + $response = $sequence->next(); + $this->assertSame(200, $response->getStatus()); + } + + public function test_gets_current_index(): void + { + $sequence = new MockResponseSequence; + $sequence->push(200, 'First'); + $sequence->push(201, 'Second'); + + $this->assertSame(0, $sequence->getCurrentIndex()); + + $sequence->next(); + $this->assertSame(1, $sequence->getCurrentIndex()); + + $sequence->next(); + $this->assertSame(2, $sequence->getCurrentIndex()); + } + + public function test_fluent_interface(): void + { + $sequence = (new MockResponseSequence) + ->push(200, 'First') + ->pushJson(['data' => 'value']) + ->pushStatus(404) + ->whenEmpty(MockResponse::ok()) + ->loop() + ->reset(); + + $this->assertInstanceOf(MockResponseSequence::class, $sequence); + $this->assertSame(3, $sequence->count()); + } +} diff --git a/tests/Unit/Testing/MockResponseTest.php b/tests/Unit/Testing/MockResponseTest.php new file mode 100644 index 0000000..d3f1b65 --- /dev/null +++ b/tests/Unit/Testing/MockResponseTest.php @@ -0,0 +1,174 @@ + 'value']); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame('Hello World', $response->getBody()); + $this->assertSame(['X-Custom' => 'value'], $response->getHeaders()); + } + + public function test_creates_json_response(): void + { + $data = ['name' => 'John', 'age' => 30]; + $response = MockResponse::json($data, 201); + + $this->assertSame(201, $response->getStatus()); + $this->assertSame(json_encode($data), $response->getBody()); + $this->assertArrayHasKey('Content-Type', $response->getHeaders()); + $this->assertSame('application/json', $response->getHeaders()['Content-Type']); + } + + public function test_sets_delay(): void + { + $response = MockResponse::create()->delay(100); + + $this->assertSame(100, $response->getDelay()); + } + + public function test_sets_throwable(): void + { + $exception = new \RuntimeException('Test error'); + $response = MockResponse::create()->throw($exception); + + $this->assertSame($exception, $response->getThrowable()); + } + + public function test_executes_response_with_delay(): void + { + $response = MockResponse::create(200, 'Test')->delay(10); + + $start = microtime(true); + $executed = $response->execute(); + $duration = (microtime(true) - $start) * 1000; // Convert to milliseconds + + $this->assertGreaterThanOrEqual(10, $duration); + $this->assertSame(200, $executed->status()); + $this->assertSame('Test', $executed->body()); + } + + public function test_executes_response_throws_exception(): void + { + $exception = new \RuntimeException('Test error'); + $response = MockResponse::create()->throw($exception); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Test error'); + + $response->execute(); + } + + public function test_creates_ok_response(): void + { + $response = MockResponse::ok('Success'); + + $this->assertSame(Status::OK->value, $response->getStatus()); + $this->assertSame('Success', $response->getBody()); + } + + public function test_creates_created_response(): void + { + $response = MockResponse::created('Resource created'); + + $this->assertSame(Status::CREATED->value, $response->getStatus()); + $this->assertSame('Resource created', $response->getBody()); + } + + public function test_creates_no_content_response(): void + { + $response = MockResponse::noContent(); + + $this->assertSame(Status::NO_CONTENT->value, $response->getStatus()); + $this->assertSame('', $response->getBody()); + } + + public function test_creates_bad_request_response(): void + { + $response = MockResponse::badRequest('Invalid input'); + + $this->assertSame(Status::BAD_REQUEST->value, $response->getStatus()); + $this->assertSame('Invalid input', $response->getBody()); + } + + public function test_creates_unauthorized_response(): void + { + $response = MockResponse::unauthorized(); + + $this->assertSame(Status::UNAUTHORIZED->value, $response->getStatus()); + } + + public function test_creates_forbidden_response(): void + { + $response = MockResponse::forbidden(); + + $this->assertSame(Status::FORBIDDEN->value, $response->getStatus()); + } + + public function test_creates_not_found_response(): void + { + $response = MockResponse::notFound(); + + $this->assertSame(Status::NOT_FOUND->value, $response->getStatus()); + } + + public function test_creates_unprocessable_entity_response(): void + { + $response = MockResponse::unprocessableEntity(['errors' => ['field' => 'required']]); + + $this->assertSame(Status::UNPROCESSABLE_ENTITY->value, $response->getStatus()); + } + + public function test_creates_server_error_response(): void + { + $response = MockResponse::serverError(); + + $this->assertSame(Status::INTERNAL_SERVER_ERROR->value, $response->getStatus()); + } + + public function test_creates_service_unavailable_response(): void + { + $response = MockResponse::serviceUnavailable(); + + $this->assertSame(Status::SERVICE_UNAVAILABLE->value, $response->getStatus()); + } + + public function test_creates_sequence(): void + { + $sequence = MockResponse::sequence([ + MockResponse::ok(), + MockResponse::created(), + ]); + + $this->assertInstanceOf(MockResponseSequence::class, $sequence); + } + + public function test_executes_response_converts_array_to_json(): void + { + $response = MockResponse::create(200, ['key' => 'value']); + + $executed = $response->execute(); + + $this->assertSame(json_encode(['key' => 'value']), $executed->body()); + } + + public function test_executes_response_keeps_string_body(): void + { + $response = MockResponse::create(200, 'Plain text'); + + $executed = $response->execute(); + + $this->assertSame('Plain text', $executed->body()); + } +} diff --git a/tests/Unit/Testing/MockServerTest.php b/tests/Unit/Testing/MockServerTest.php new file mode 100644 index 0000000..6b89fea --- /dev/null +++ b/tests/Unit/Testing/MockServerTest.php @@ -0,0 +1,308 @@ +handleRequest($request); + + $this->assertNotNull($response); + $this->assertSame(200, $response->status()); + } + + public function test_fakes_specific_url_pattern(): void + { + MockServer::fake([ + 'https://api.example.com/users' => MockResponse::json(['users' => []]), + ]); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $response = MockServer::getInstance()->handleRequest($request); + + $this->assertNotNull($response); + $this->assertSame(['users' => []], $response->json()); + } + + public function test_fakes_with_method_and_url(): void + { + MockServer::fake([ + 'GET https://api.example.com/users' => MockResponse::json(['users' => []]), + 'POST https://api.example.com/users' => MockResponse::created(), + ]); + + $getRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $getResponse = MockServer::getInstance()->handleRequest($getRequest); + + $postRequest = Request::createFromBase(new GuzzleRequest('POST', 'https://api.example.com/users')); + $postResponse = MockServer::getInstance()->handleRequest($postRequest); + + $this->assertSame(200, $getResponse->status()); + $this->assertSame(201, $postResponse->status()); + } + + public function test_fakes_with_wildcard_pattern(): void + { + MockServer::fake([ + 'https://api.example.com/users/*' => MockResponse::json(['id' => 1]), + ]); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users/123')); + $response = MockServer::getInstance()->handleRequest($request); + + $this->assertNotNull($response); + $this->assertSame(['id' => 1], $response->json()); + } + + public function test_fakes_with_callback(): void + { + MockServer::fake(function (Request $request) { + if (str_contains((string) $request->getUri(), 'users')) { + return MockResponse::json(['type' => 'users']); + } + + return MockResponse::json(['type' => 'other']); + }); + + $usersRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $usersResponse = MockServer::getInstance()->handleRequest($usersRequest); + + $otherRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/posts')); + $otherResponse = MockServer::getInstance()->handleRequest($otherRequest); + + $this->assertSame('users', $usersResponse->json()['type']); + $this->assertSame('other', $otherResponse->json()['type']); + } + + public function test_fakes_with_sequence(): void + { + $sequence = MockResponse::sequence() + ->push(500, 'Error') + ->push(200, 'Success'); + + MockServer::fake([ + 'https://api.example.com/flaky' => $sequence, + ]); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/flaky')); + + $firstResponse = MockServer::getInstance()->handleRequest($request); + $this->assertSame(500, $firstResponse->status()); + $this->assertSame('Error', $firstResponse->body()); + + $secondResponse = MockServer::getInstance()->handleRequest($request); + $this->assertSame(200, $secondResponse->status()); + $this->assertSame('Success', $secondResponse->body()); + } + + public function test_prevents_stray_requests(): void + { + MockServer::fake([ + 'https://allowed.com/*' => MockResponse::ok(), + ]); + MockServer::preventStrayRequests(); + + $allowedRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://allowed.com/path')); + $allowedResponse = MockServer::getInstance()->handleRequest($allowedRequest); + $this->assertNotNull($allowedResponse); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No fake response registered'); + + $strayRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://other.com/path')); + MockServer::getInstance()->handleRequest($strayRequest); + } + + public function test_allows_stray_requests_with_patterns(): void + { + MockServer::fake([ + 'https://api.example.com/*' => MockResponse::ok(), + ]); + MockServer::allowStrayRequests(['https://localhost/*']); + + $apiRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $apiResponse = MockServer::getInstance()->handleRequest($apiRequest); + $this->assertNotNull($apiResponse); + + $localhostRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://localhost/test')); + $localhostResponse = MockServer::getInstance()->handleRequest($localhostRequest); + $this->assertNull($localhostResponse); // Allowed to go through + } + + public function test_records_requests(): void + { + MockServer::fake([ + 'https://api.example.com/*' => MockResponse::ok('Success'), + ]); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + MockServer::getInstance()->handleRequest($request); + + $recorded = MockServer::recorded(); + + $this->assertCount(1, $recorded); + $this->assertArrayHasKey('request', $recorded[0]); + $this->assertArrayHasKey('response', $recorded[0]); + $this->assertSame('GET', $recorded[0]['request']->getMethod()); + $this->assertSame('Success', $recorded[0]['response']->body()); + } + + public function test_records_with_filter(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + $getRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $postRequest = Request::createFromBase(new GuzzleRequest('POST', 'https://api.example.com/users')); + + MockServer::getInstance()->handleRequest($getRequest); + MockServer::getInstance()->handleRequest($postRequest); + + $recorded = MockServer::recorded(fn ($record) => $record['request']->getMethod() === 'POST'); + + $this->assertCount(1, $recorded); + } + + public function test_asserts_sent(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + $request = Request::createFromBase(new GuzzleRequest('POST', 'https://api.example.com/users')); + MockServer::getInstance()->handleRequest($request); + + MockServer::assertSent('POST https://api.example.com/users'); + MockServer::assertSent('https://api.example.com/users'); + MockServer::assertSent('*'); + } + + public function test_asserts_sent_with_callback(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + $psrRequest = new GuzzleRequest('POST', 'https://api.example.com/users', ['Authorization' => 'Bearer token']); + $request = Request::createFromBase($psrRequest); + MockServer::getInstance()->handleRequest($request); + + MockServer::assertSent(function (Request $request) { + return $request->hasHeader('Authorization'); + }); + } + + public function test_asserts_sent_with_times(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + MockServer::getInstance()->handleRequest($request); + MockServer::getInstance()->handleRequest($request); + + MockServer::assertSent('https://api.example.com/users', 2); + } + + public function test_asserts_not_sent(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + MockServer::getInstance()->handleRequest($request); + + MockServer::assertNotSent('https://api.example.com/posts'); + } + + public function test_asserts_sent_count(): void + { + MockServer::fake([ + '*' => MockResponse::ok(), + ]); + + $request1 = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $request2 = Request::createFromBase(new GuzzleRequest('POST', 'https://api.example.com/posts')); + + MockServer::getInstance()->handleRequest($request1); + MockServer::getInstance()->handleRequest($request2); + + MockServer::assertSentCount(2); + } + + public function test_asserts_nothing_sent(): void + { + MockServer::fake(); + + MockServer::assertNothingSent(); + } + + public function test_resets_state(): void + { + MockServer::fake([ + 'https://api.example.com/*' => MockResponse::ok(), + ]); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + MockServer::getInstance()->handleRequest($request); + + $this->assertCount(1, MockServer::recorded()); + + MockServer::getInstance()->reset(); + + $this->assertCount(0, MockServer::recorded()); + } + + public function test_callback_returning_array_converts_to_json(): void + { + MockServer::fake(fn () => ['data' => 'value']); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://example.com')); + $response = MockServer::getInstance()->handleRequest($request); + + $this->assertSame(['data' => 'value'], $response->json()); + } + + public function test_wildcard_matches_any_path(): void + { + MockServer::fake([ + 'https://api.example.com/*' => MockResponse::ok('Matched'), + ]); + + $request1 = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $request2 = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/posts/123')); + $request3 = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/deeply/nested/path')); + + $this->assertSame('Matched', MockServer::getInstance()->handleRequest($request1)->body()); + $this->assertSame('Matched', MockServer::getInstance()->handleRequest($request2)->body()); + $this->assertSame('Matched', MockServer::getInstance()->handleRequest($request3)->body()); + } +} diff --git a/tests/Unit/Testing/RecorderTest.php b/tests/Unit/Testing/RecorderTest.php new file mode 100644 index 0000000..2261d2d --- /dev/null +++ b/tests/Unit/Testing/RecorderTest.php @@ -0,0 +1,262 @@ +assertFalse(Recorder::isRecording()); + + Recorder::start(); + + $this->assertTrue(Recorder::isRecording()); + } + + public function test_stops_recording(): void + { + Recorder::start(); + $this->assertTrue(Recorder::isRecording()); + + Recorder::stop(); + + $this->assertFalse(Recorder::isRecording()); + } + + public function test_records_request_and_response(): void + { + Recorder::start(); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://example.com')); + $response = Response::createFromBase(new GuzzleResponse(200, [], 'Test')); + + Recorder::record($request, $response); + + $recordings = Recorder::getRecordings(); + + $this->assertCount(1, $recordings); + $this->assertArrayHasKey('request', $recordings[0]); + $this->assertArrayHasKey('response', $recordings[0]); + $this->assertArrayHasKey('timestamp', $recordings[0]); + $this->assertSame('GET', $recordings[0]['request']->getMethod()); + $this->assertSame('Test', $recordings[0]['response']->body()); + } + + public function test_does_not_record_when_not_active(): void + { + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://example.com')); + $response = Response::createFromBase(new GuzzleResponse(200, [], 'Test')); + + Recorder::record($request, $response); + + $recordings = Recorder::getRecordings(); + + $this->assertCount(0, $recordings); + } + + public function test_clears_recordings_on_start(): void + { + Recorder::start(); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://example.com')); + $response = Response::createFromBase(new GuzzleResponse(200, [], 'Test')); + Recorder::record($request, $response); + + $this->assertCount(1, Recorder::getRecordings()); + + Recorder::start(); // Start again should clear + + $this->assertCount(0, Recorder::getRecordings()); + } + + public function test_stop_returns_recordings(): void + { + Recorder::start(); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://example.com')); + $response = Response::createFromBase(new GuzzleResponse(200, [], 'Test')); + Recorder::record($request, $response); + + $recordings = Recorder::stop(); + + $this->assertCount(1, $recordings); + $this->assertSame('GET', $recordings[0]['request']->getMethod()); + } + + public function test_clears_recordings(): void + { + Recorder::start(); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://example.com')); + $response = Response::createFromBase(new GuzzleResponse(200, [], 'Test')); + Recorder::record($request, $response); + + $this->assertCount(1, Recorder::getRecordings()); + + Recorder::clear(); + + $this->assertCount(0, Recorder::getRecordings()); + } + + public function test_replays_recordings(): void + { + $recordings = [ + [ + 'request' => Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')), + 'response' => Response::createFromBase(new GuzzleResponse(200, [], json_encode(['users' => []]))), + ], + [ + 'request' => Request::createFromBase(new GuzzleRequest('POST', 'https://api.example.com/users')), + 'response' => Response::createFromBase(new GuzzleResponse(201, [], json_encode(['id' => 1]))), + ], + ]; + + Recorder::replay($recordings); + + // Verify that MockServer has been set up with these recordings + $getRequest = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $getResponse = MockServer::getInstance()->handleRequest($getRequest); + + $this->assertNotNull($getResponse); + $this->assertSame(['users' => []], $getResponse->json()); + + $postRequest = Request::createFromBase(new GuzzleRequest('POST', 'https://api.example.com/users')); + $postResponse = MockServer::getInstance()->handleRequest($postRequest); + + $this->assertNotNull($postResponse); + $this->assertSame(['id' => 1], $postResponse->json()); + } + + public function test_replays_multiple_requests_to_same_endpoint(): void + { + $recordings = [ + [ + 'request' => Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')), + 'response' => Response::createFromBase(new GuzzleResponse(200, [], 'First')), + ], + [ + 'request' => Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')), + 'response' => Response::createFromBase(new GuzzleResponse(200, [], 'Second')), + ], + ]; + + Recorder::replay($recordings); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + + $firstResponse = MockServer::getInstance()->handleRequest($request); + $this->assertSame('First', $firstResponse->body()); + + $secondResponse = MockServer::getInstance()->handleRequest($request); + $this->assertSame('Second', $secondResponse->body()); + } + + public function test_exports_to_json(): void + { + Recorder::start(); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users', ['Authorization' => 'Bearer token'])); + $response = Response::createFromBase(new GuzzleResponse(200, ['Content-Type' => 'application/json'], json_encode(['users' => []]))); + + Recorder::record($request, $response); + + $json = Recorder::exportToJson(); + + $this->assertJson($json); + + $data = json_decode($json, true); + $this->assertCount(1, $data); + $this->assertSame('GET', $data[0]['request']['method']); + $this->assertSame('https://api.example.com/users', $data[0]['request']['url']); + $this->assertArrayHasKey('Authorization', $data[0]['request']['headers']); + $this->assertSame(200, $data[0]['response']['status']); + } + + public function test_imports_from_json(): void + { + $json = json_encode([ + [ + 'request' => [ + 'method' => 'GET', + 'url' => 'https://api.example.com/users', + 'headers' => ['Authorization' => ['Bearer token']], + 'body' => '', + ], + 'response' => [ + 'status' => 200, + 'headers' => ['Content-Type' => ['application/json']], + 'body' => json_encode(['users' => []]), + ], + 'timestamp' => microtime(true), + ], + ]); + + Recorder::importFromJson($json); + + // Verify that MockServer has been set up + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://api.example.com/users')); + $response = MockServer::getInstance()->handleRequest($request); + + $this->assertNotNull($response); + $this->assertSame(200, $response->status()); + $this->assertSame(['users' => []], $response->json()); + } + + public function test_imports_invalid_json_throws_exception(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JSON format'); + + Recorder::importFromJson('invalid json'); + } + + public function test_resets_recorder(): void + { + Recorder::start(); + + $request = Request::createFromBase(new GuzzleRequest('GET', 'https://example.com')); + $response = Response::createFromBase(new GuzzleResponse(200, [], 'Test')); + Recorder::record($request, $response); + + $this->assertTrue(Recorder::isRecording()); + $this->assertCount(1, Recorder::getRecordings()); + + Recorder::reset(); + + $this->assertFalse(Recorder::isRecording()); + $this->assertCount(0, Recorder::getRecordings()); + } + + public function test_start_enables_mock_server_recording(): void + { + Recorder::start(); + + // This should enable recording in MockServer as well + // We can't directly test this without integration, but we can verify it doesn't error + $this->assertTrue(Recorder::isRecording()); + } +}