diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fe9959e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2025-11-29 + +### Changed +- **BREAKING**: Replaced hard Guzzle dependency with PSR-18 (HTTP Client) and PSR-17 (HTTP Factories) interfaces +- Module now depends on PSR standards instead of concrete Guzzle implementation +- HTTP client architecture refactored to support any PSR-18/PSR-17 compliant implementation + +### Added +- PSR-18 `ClientInterface` support for true dependency inversion +- PSR-17 `RequestFactoryInterface` and `StreamFactoryInterface` support +- Optional auto-discovery: automatically creates Guzzle instances if available (backward compatible) +- Support for any PSR-18/PSR-17 compliant HTTP client (Guzzle, Symfony HttpClient, custom implementations) +- New configuration options: `httpClient`, `requestFactory`, `streamFactory` +- Private initialization methods: `initHttpClient()`, `initRequestFactory()`, `initStreamFactory()` + +### Removed +- Hard dependency on `guzzlehttp/guzzle` (now optional, moved to `require-dev`) + +## [1.0.0] - 2025-11-28 + +### Added +- Initial release of Codeception WireMock integration module +- `haveHttpStubFor()` - Create HTTP stubs for any method with advanced request matching +- `seeHttpRequest()` - Verify HTTP requests were made with pattern matching +- `dontSeeHttpRequest()` - Verify HTTP requests were NOT made +- `seeRequestCount()` - Assert exact number of matching requests +- `grabRequestCount()` - Retrieve count of matching requests +- `grabAllRequests()` - Retrieve all recorded requests for debugging +- `grabUnmatchedRequests()` - Retrieve requests that didn't match any stub +- `sendReset()` - Reset WireMock to default state +- `sendClearRequests()` - Clear request journal without affecting stubs +- Automatic cleanup hooks (`cleanupBefore: test|suite|never`) +- Near-miss analysis for failed request verifications +- Support for advanced request matching (body patterns, headers, query parameters) +- WireMock health check on module initialization +- Comprehensive test coverage (15 unit tests, 11 functional tests) +- Guzzle HTTP client integration diff --git a/CLAUDE.md b/CLAUDE.md index ec10446..cc37637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,19 +9,28 @@ This is a Codeception module for integrating with WireMock, a tool for mocking H **Key Details:** - PHP 8.2+ required - Codeception 5.3+ required -- Guzzle HTTP client 7.8+ required +- PSR-18 (HTTP Client) and PSR-17 (HTTP Factories) required +- Guzzle HTTP client optional (auto-discovered if available) - MIT licensed - PSR-4 autoloading structure +**Architecture:** +- Depends on PSR-18/PSR-17 interfaces (true dependency inversion) +- No hard dependency on Guzzle - any PSR-compliant HTTP client works +- Optional auto-discovery creates Guzzle instances if available +- Users can inject their own PSR-18/PSR-17 implementations + ## Project Structure ``` src/JasonBenett/CodeceptionModuleWiremock/ ├── Module/ │ └── Wiremock.php # Main module class with all public methods -└── Exception/ - ├── WiremockException.php # Base exception - └── RequestVerificationException.php # Verification failure exception +├── Exception/ +│ ├── WiremockException.php # Base exception +│ └── RequestVerificationException.php # Verification failure exception +└── Http/ + └── HttpClientException.php # PSR-18 ClientExceptionInterface implementation tests/ ├── unit/ @@ -58,6 +67,115 @@ docker-compose down # Stop WireMock server docker-compose logs wiremock # View WireMock logs ``` +## Development Workflow & Conventions + +### Semantic Commit Messages + +**IMPORTANT:** This project uses [Conventional Commits](https://www.conventionalcommits.org/) specification for all commit messages. + +**Format:** +``` +(): + +[optional body] + +[optional footer(s)] +``` + +**Types:** +- `feat:` - New feature for users +- `fix:` - Bug fix for users +- `docs:` - Documentation changes +- `style:` - Code style changes (formatting, missing semi colons, etc) +- `refactor:` - Code refactoring (neither fixes a bug nor adds a feature) +- `perf:` - Performance improvements +- `test:` - Adding or updating tests +- `chore:` - Changes to build process, CI, dependencies, etc + +**Examples:** +```bash +# Feature addition +git commit -m "feat: add support for delayed stub responses" + +# Bug fix +git commit -m "fix: handle empty response body in makeAdminRequest" + +# Refactoring +git commit -m "refactor: extract HTTP client abstraction to use PSR-18" + +# Documentation +git commit -m "docs: update README with PSR configuration examples" + +# Breaking change +git commit -m "feat!: replace Guzzle with PSR-18 interfaces + +BREAKING CHANGE: httpClient, requestFactory, and streamFactory are now required configuration options" +``` + +**All commits MUST:** +1. Follow the conventional commits format +2. Include the footer: `🤖 Generated with [Claude Code](https://claude.com/claude-code)` +3. Include: `Co-Authored-By: Claude ` + +### CHANGELOG Maintenance + +**IMPORTANT:** The CHANGELOG.md file MUST be updated for every user-facing change. + +**Format:** We follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. + +**Workflow:** +1. **During Development:** Add changes to the `[Unreleased]` section under appropriate category: + - `### Added` - New features + - `### Changed` - Changes in existing functionality + - `### Deprecated` - Soon-to-be removed features + - `### Removed` - Removed features + - `### Fixed` - Bug fixes + - `### Security` - Security fixes + +2. **Before Release:** Move `[Unreleased]` changes to a new version section: + ```markdown + ## [1.1.0] - 2025-02-15 + + ### Added + - New feature description + ``` + +3. **Update Guidelines:** + - Write for users, not developers (focus on behavior, not implementation) + - Be specific about what changed and why users care + - Link to issues/PRs when relevant: `(#123)` + - For breaking changes, explain migration path + +**Example Entry:** +```markdown +## [Unreleased] + +### Added +- Support for custom HTTP headers in all stub methods + +### Changed +- `haveHttpStubFor()` now validates request matchers before sending to WireMock + +### Fixed +- Near-miss analysis now handles special characters in URLs correctly +``` + +### Git Workflow + +1. **Before Committing:** + - Run all quality checks: `composer test && composer phpstan && composer cs-check` + - Update CHANGELOG.md if user-facing changes + - Verify all tests pass + +2. **Committing:** + - Use semantic commit message format + - Include Claude Code footer + +3. **Pull Requests:** + - Ensure CI passes (all PHP versions, all checks) + - Update README.md if API changes + - Update CHANGELOG.md in Unreleased section + ## Module Architecture ### Configuration Options @@ -68,16 +186,19 @@ The module accepts the following configuration in `codeception.yml`: modules: enabled: - \JasonBenett\CodeceptionModuleWiremock\Module\Wiremock: - host: localhost # WireMock host (default: 127.0.0.1) - port: 8080 # WireMock port (default: 8080) - protocol: http # Protocol (default: http) - timeout: 10.0 # Request timeout in seconds (default: 10.0) - cleanupBefore: test # When to cleanup: 'never', 'test', or 'suite' (default: test) - preserveFileMappings: true # Keep file-based stubs on reset (default: true) - verifySSL: true # Verify SSL certificates (default: true) - adminPath: /__admin # Admin API path (default: /__admin) + host: localhost # Required: WireMock host (default: 127.0.0.1) + port: 8080 # Required: WireMock port (default: 8080) + protocol: http # Optional: Protocol (default: http) + cleanupBefore: test # Optional: When to cleanup: 'never', 'test', or 'suite' (default: test) + preserveFileMappings: true # Optional: Keep file-based stubs on reset (default: true) + adminPath: /__admin # Optional: Admin API path (default: /__admin) + httpClient: null # Optional: PSR-18 ClientInterface instance (auto-creates Guzzle if null) + requestFactory: null # Optional: PSR-17 RequestFactoryInterface instance (auto-creates if null) + streamFactory: null # Optional: PSR-17 StreamFactoryInterface instance (auto-creates if null) ``` +**Note:** If PSR client instances are not provided, the module will automatically create Guzzle instances if guzzlehttp/guzzle is installed. For custom HTTP clients, provide PSR-18/PSR-17 implementations. + ### Public Methods (MVP) #### Setup Methods (have*) @@ -99,10 +220,17 @@ modules: ### Lifecycle Hooks -- `_initialize()` - Creates Guzzle HTTP client and verifies WireMock connectivity +- `_initialize()` - Validates/creates PSR-18/PSR-17 clients and verifies WireMock connectivity - `_beforeSuite()` - Cleanup if `cleanupBefore: suite` - `_before()` - Cleanup if `cleanupBefore: test` (default behavior) +### Internal Methods + +- `initHttpClient(): void` - Get PSR-18 client from config or auto-create Guzzle instance +- `initRequestFactory(): void` - Get PSR-17 request factory from config or auto-create +- `initStreamFactory(RequestFactoryInterface): void` - Get PSR-17 stream factory from config or auto-create +- `makeAdminRequest(string, string, array): array` - Make HTTP request to WireMock Admin API using PSR-18/PSR-17 + ### WireMock Admin API Endpoints Used - `POST /__admin/mappings` - Create stub mapping diff --git a/README.md b/README.md index 72b2dd9..70347eb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ A Codeception module for WireMock integration, allowing you to mock HTTP service - PHP 8.2 or higher - Codeception 5.3 or higher +- A PSR-18 HTTP Client implementation (e.g., Guzzle, Symfony HttpClient) +- A PSR-17 HTTP Factory implementation (e.g., guzzlehttp/psr7) - A running WireMock server ## Installation @@ -33,6 +35,35 @@ Install via Composer: composer require jasonbenett/codeception-module-wiremock ``` +This module depends on PSR-18 (HTTP Client) and PSR-17 (HTTP Factories) interfaces. You'll need to install a compatible implementation: + +**Using Guzzle (recommended):** +```bash +composer require guzzlehttp/guzzle +``` + +**Using Symfony HttpClient:** +```bash +composer require symfony/http-client nyholm/psr7 +``` + +**Other PSR-18/PSR-17 implementations work as well.** + +## Architecture + +This module follows **PSR-18** (HTTP Client) and **PSR-17** (HTTP Factories) standards, providing true dependency inversion: + +- **No hard dependency on Guzzle** - Use any PSR-compliant HTTP client +- **Framework agnostic** - Works with Symfony HttpClient, Guzzle, or custom clients +- **Optional auto-discovery** - Automatically creates Guzzle instances if available +- **Full control** - Inject your own configured PSR clients for advanced scenarios + +This approach allows you to: +- Choose your preferred HTTP client library +- Control HTTP client configuration (timeouts, SSL, proxies, etc.) +- Test with mock PSR-18 clients +- Upgrade HTTP client versions independently + ## Quick Start ### 1. Start WireMock Server @@ -43,12 +74,6 @@ Using Docker (recommended): docker run -d -p 8080:8080 wiremock/wiremock:latest ``` -Or use the included `docker-compose.yml`: - -```bash -docker-compose up -d -``` - ### 2. Configure Codeception Add the WireMock module to your `codeception.yml` or suite configuration: @@ -88,7 +113,9 @@ class ApiTestCest ## Configuration Options -All configuration options and their defaults: +### Basic Configuration (Auto-Discovery) + +When using Guzzle, the module can auto-create PSR client instances: ```yaml modules: @@ -97,13 +124,65 @@ modules: host: 127.0.0.1 # WireMock server host port: 8080 # WireMock server port protocol: http # Protocol (http or https) - timeout: 10.0 # Request timeout in seconds cleanupBefore: test # When to cleanup: 'never', 'test', or 'suite' preserveFileMappings: true # Keep file-based stubs on reset - verifySSL: true # Verify SSL certificates adminPath: /__admin # Admin API path ``` +### Advanced Configuration (Custom PSR Clients) + +For full control and dependency inversion, provide your own PSR-18/PSR-17 implementations: + +```php +// tests/_bootstrap.php or tests/_support/Helper/HttpClientProvider.php + +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; + +// Create PSR-18 HTTP Client +$httpClient = new Client([ + 'timeout' => 10.0, + 'verify' => false, // Disable SSL verification if needed + 'http_errors' => false, +]); + +// Create PSR-17 factories (Guzzle's HttpFactory implements both interfaces) +$httpFactory = new HttpFactory(); + +// Store in global for Codeception access +$GLOBALS['wiremock_http_client'] = $httpClient; +$GLOBALS['wiremock_request_factory'] = $httpFactory; +$GLOBALS['wiremock_stream_factory'] = $httpFactory; +``` + +```yaml +# codeception.yml or suite config +modules: + enabled: + - \JasonBenett\CodeceptionModuleWiremock\Module\Wiremock: + host: 127.0.0.1 + port: 8080 + httpClient: !php/const GLOBALS['wiremock_http_client'] + requestFactory: !php/const GLOBALS['wiremock_request_factory'] + streamFactory: !php/const GLOBALS['wiremock_stream_factory'] +``` + +### All Configuration Options + +```yaml +host: 127.0.0.1 # Required: WireMock server host +port: 8080 # Required: WireMock server port +protocol: http # Optional: Protocol (http or https) +cleanupBefore: test # Optional: When to cleanup: 'never', 'test', or 'suite' +preserveFileMappings: true # Optional: Keep file-based stubs on reset +adminPath: /__admin # Optional: Admin API path +httpClient: null # Optional: PSR-18 ClientInterface instance +requestFactory: null # Optional: PSR-17 RequestFactoryInterface instance +streamFactory: null # Optional: PSR-17 StreamFactoryInterface instance +``` + +**Note:** If `httpClient`, `requestFactory`, or `streamFactory` are not provided, the module will attempt to auto-create Guzzle instances if available. + ## Available Methods ### Setup Methods (have*) @@ -477,7 +556,7 @@ Every push and pull request is automatically tested via GitHub Actions across mu - ✅ **PHP 8.2, 8.3, 8.4** - Full compatibility testing - ✅ **PHPStan Level Max** - Zero errors in static analysis - ✅ **PER Coding Style 3.0** - Strict code style compliance -- ✅ **100% Test Coverage** - 26 passing tests (15 unit + 11 functional) +- ✅ **Testing** - Unit and Functional - ✅ **WireMock Integration Tests** - Tests against real WireMock server ### Local Development @@ -501,7 +580,7 @@ composer test:functional ### Code Quality Metrics - **PHPStan**: Max level, zero errors -- **Code Coverage**: 100% with Codecov reporting +- **Code Coverage**: with Codecov reporting - **Code Style**: PER Coding Style 3.0 (successor to PSR-12) - **Type Safety**: Full PHPDoc annotations with array shapes - **Documentation**: Comprehensive inline documentation diff --git a/composer.json b/composer.json index 197ea22..9f30a26 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "keywords": [ "codeception", "functional testing", - "wiremock" + "wiremock", + "testing" ], "authors": [ { @@ -18,12 +19,15 @@ "php": "^8.2", "ext-json": "*", "codeception/codeception": "^5.3", - "guzzlehttp/guzzle": "^7.8" + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" }, "require-dev": { "codeception/module-asserts": "^3.2", "codeception/stub": "^4.1", "friendsofphp/php-cs-fixer": "^3.90", + "guzzlehttp/guzzle": "^7.8", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^11.0" }, @@ -34,7 +38,9 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Tests\\Unit\\": "tests/unit", + "Tests\\Functional\\": "tests/functional" } }, "config": { diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 00ab4dc..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - wiremock: - image: wiremock/wiremock:latest - container_name: wiremock - ports: - - "8080:8080" - command: - - --global-response-templating - - --verbose - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 5s diff --git a/src/JasonBenett/CodeceptionModuleWiremock/Http/HttpClientException.php b/src/JasonBenett/CodeceptionModuleWiremock/Http/HttpClientException.php new file mode 100644 index 0000000..cbab33a --- /dev/null +++ b/src/JasonBenett/CodeceptionModuleWiremock/Http/HttpClientException.php @@ -0,0 +1,15 @@ + Module configuration */ protected array $config = [ 'host' => '127.0.0.1', 'port' => 8080, 'protocol' => 'http', - 'timeout' => 10.0, 'cleanupBefore' => 'test', // Options: 'never', 'test', 'suite' 'preserveFileMappings' => true, - 'verifySSL' => true, 'adminPath' => '/__admin', + 'httpClient' => null, // PSR-18 ClientInterface instance + 'requestFactory' => null, // PSR-17 RequestFactoryInterface instance + 'streamFactory' => null, // PSR-17 StreamFactoryInterface instance ]; /** @var array Required configuration fields */ protected array $requiredFields = ['host', 'port']; - protected ?Client $client = null; + protected ClientInterface $httpClient; + protected RequestFactoryInterface $requestFactory; + protected StreamFactoryInterface $streamFactory; protected string $baseUrl; /** - * Initialize the module - create HTTP client and verify connectivity + * Initialize the module - validate PSR dependencies and verify connectivity * - * @throws ModuleException + * @throws ModuleException If configuration is invalid or WireMock is not accessible */ public function _initialize(): void { @@ -57,27 +65,24 @@ public function _initialize(): void $adminPath, ); - $this->client = new Client([ - 'base_uri' => $this->baseUrl . '/', - 'timeout' => $this->config['timeout'], - 'verify' => $this->config['verifySSL'], - 'http_errors' => false, - ]); + $this->initHttpClient(); + $this->initRequestFactory(); + $this->initStreamFactory(); - // Verify WireMock is accessible try { - $response = $this->client->request('GET', 'health'); + $request = $this->requestFactory->createRequest('GET', $this->baseUrl . '/health'); + $response = $this->httpClient->sendRequest($request); - if ($response->getStatusCode() >= 400) { + if ($response->getStatusCode() >= self::HTTP_BAD_REQUEST) { throw new ModuleException( $this, - "WireMock health check failed at {$this->baseUrl}/health", + sprintf('WireMock health check failed at %s/health', $this->baseUrl), ); } - } catch (GuzzleException $exception) { + } catch (ClientExceptionInterface $exception) { throw new ModuleException( $this, - "Cannot connect to WireMock at {$this->baseUrl}: " . $exception->getMessage(), + sprintf('Cannot connect to WireMock at %s: %s', $this->baseUrl, $exception->getMessage()), ); } } @@ -192,12 +197,12 @@ public function seeHttpRequest( if ($count === 0) { // Try to get near misses for better error message $nearMissesData = $this->fetchNearMisses($pattern); - $message = "Expected request not found: {$method} {$url}"; + $message = sprintf('Expected request not found: %s %s', $method, $url); $nearMisses = $nearMissesData['nearMisses'] ?? null; if (is_array($nearMisses) && !empty($nearMisses)) { /** @var array> $nearMisses */ - $message .= "\n\nNear misses found:\n" . $this->formatNearMisses($nearMisses); + $message = sprintf("%s\n\nNear misses found:\n%s", $message, $this->formatNearMisses($nearMisses)); } throw new RequestVerificationException($message); @@ -231,7 +236,7 @@ public function dontSeeHttpRequest( if ($count > 0) { throw new RequestVerificationException( - "Unexpected request found: {$method} {$url} (found {$count} match(es))", + sprintf('Unexpected request found: %s %s (found %d match(es))', $method, $url, $count), ); } @@ -254,7 +259,7 @@ public function seeRequestCount(int $expectedCount, array $requestPattern): void if ($actualCount !== $expectedCount) { throw new RequestVerificationException( - "Expected {$expectedCount} request(s), but found {$actualCount}", + sprintf('Expected %d request(s), but found %d', $expectedCount, $actualCount), ); } @@ -365,7 +370,7 @@ protected function cleanup(): void } /** - * Make HTTP request to WireMock Admin API + * Make HTTP request to WireMock Admin API using PSR-18/PSR-17 * * @param string $method HTTP method * @param string $endpoint Admin API endpoint (relative to admin path) @@ -374,31 +379,37 @@ protected function cleanup(): void * @return array Response data decoded from JSON * * @throws WiremockException If WireMock request fails or communication fails - * @throws JsonException If JSON decoding fails + * @throws JsonException If JSON encoding fails */ protected function makeAdminRequest( string $method, string $endpoint, array $data = [], ): array { - if ($this->client === null) { - throw new WiremockException('HTTP client is not initialized'); - } + $uri = $this->baseUrl . '/' . ltrim($endpoint, '/'); try { - $options = []; + $request = $this->requestFactory->createRequest($method, $uri); if (!empty($data)) { - $options['json'] = $data; + $jsonBody = json_encode($data, JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + $request = $request + ->withBody($stream) + ->withHeader('Content-Type', 'application/json'); } - $response = $this->client->request($method, $endpoint, $options); + $response = $this->httpClient->sendRequest($request); $statusCode = $response->getStatusCode(); $body = (string) $response->getBody(); - if ($statusCode >= 400) { + if ($statusCode >= self::HTTP_BAD_REQUEST) { throw new WiremockException( - "WireMock request failed with status {$statusCode}: {$body}", + sprintf( + 'WireMock request failed with status %d: %s', + $statusCode, + $body, + ), ); } @@ -415,9 +426,9 @@ protected function makeAdminRequest( /** @var array $decoded */ return $decoded; - } catch (GuzzleException $exception) { + } catch (ClientExceptionInterface $exception) { throw new WiremockException( - 'Failed to communicate with WireMock: ' . $exception->getMessage(), + sprintf('Failed to communicate with WireMock: %s', $exception->getMessage()), 0, $exception, ); @@ -485,4 +496,116 @@ protected function formatNearMisses(array $nearMisses): string return implode("\n", $output); } + + /** + * Get HTTP client from config or create default Guzzle client + * + * @return void + * + * @throws ModuleException If no client provided and Guzzle is not available + */ + private function initHttpClient(): void + { + $httpClient = $this->config['httpClient']; + + if ($httpClient === null) { + if (!class_exists('\\GuzzleHttp\\Client')) { + throw new ModuleException( + $this, + 'No httpClient provided and GuzzleHTTP is not available. Either provide a PSR-18 ClientInterface or install guzzlehttp/guzzle.', + ); + } + + $this->httpClient = new \GuzzleHttp\Client([ + 'timeout' => 10.0, + 'http_errors' => false, + ]); + + return; + } + + if (!$httpClient instanceof ClientInterface) { + throw new ModuleException( + $this, + sprintf('Configuration "httpClient" must be an instance of %s', ClientInterface::class), + ); + } + + $this->httpClient = $httpClient; + } + + /** + * Get request factory from config or create default Guzzle factory + * + * @return void + * + * @throws ModuleException If no factory provided and Guzzle PSR-7 is not available + */ + private function initRequestFactory(): void + { + $requestFactory = $this->config['requestFactory']; + + if ($requestFactory === null) { + if (!class_exists('\\GuzzleHttp\\Psr7\\HttpFactory')) { + throw new ModuleException( + $this, + 'No requestFactory provided and GuzzleHTTP PSR-17 factory is not available. Either provide a PSR-17 RequestFactoryInterface or install guzzlehttp/psr7.', + ); + } + + $this->requestFactory = new \GuzzleHttp\Psr7\HttpFactory(); + + return; + } + + if (!$requestFactory instanceof RequestFactoryInterface) { + throw new ModuleException( + $this, + sprintf('Configuration "requestFactory" must be an instance of %s', RequestFactoryInterface::class), + ); + } + + $this->requestFactory = $requestFactory; + } + + /** + * Get stream factory from config or create default factory + * + * @return void + * + * @throws ModuleException If no factory provided and no compatible factory available + */ + private function initStreamFactory(): void + { + $streamFactory = $this->config['streamFactory']; + + if ($streamFactory === null) { + if ($this->requestFactory instanceof StreamFactoryInterface) { + $this->streamFactory = $this->requestFactory; + + return; + } + + // Otherwise create Guzzle PSR-17 factory + if (class_exists('\\GuzzleHttp\\Psr7\\HttpFactory')) { + $this->streamFactory = new \GuzzleHttp\Psr7\HttpFactory(); + + return; + } + + throw new ModuleException( + $this, + 'No streamFactory provided and GuzzleHTTP PSR-17 factory is not available. Either provide a PSR-17 StreamFactoryInterface or install guzzlehttp/psr7.', + ); + } + + if (!$streamFactory instanceof StreamFactoryInterface) { + throw new ModuleException( + $this, + sprintf('Configuration "streamFactory" must be an instance of %s', StreamFactoryInterface::class), + ); + } + + $this->streamFactory = $streamFactory; + } } diff --git a/tests/unit/Codeception/Module/WiremockTest.php b/tests/unit/Codeception/Module/WiremockTest.php index 8dc12e5..f078371 100644 --- a/tests/unit/Codeception/Module/WiremockTest.php +++ b/tests/unit/Codeception/Module/WiremockTest.php @@ -9,6 +9,7 @@ use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\HttpFactory; use GuzzleHttp\Psr7\Response; use JasonBenett\CodeceptionModuleWiremock\Exception\RequestVerificationException; use JasonBenett\CodeceptionModuleWiremock\Exception\WiremockException; @@ -17,40 +18,51 @@ final class WiremockTest extends Unit { - protected static array $config = [ - 'host' => '127.0.0.1', - 'port' => 8080, - ]; - protected ?Wiremock $module = null; protected MockHandler $mockHandler; + protected Client $client; + protected HttpFactory $httpFactory; protected function _setUp(): void { - $container = $this->createMock(ModuleContainer::class); - $this->module = new Wiremock($container); - $this->module->_setConfig(self::$config); - - // Mock HTTP client + // Mock HTTP client with PSR-18 $this->mockHandler = new MockHandler(); $handlerStack = HandlerStack::create($this->mockHandler); - $client = new Client(['handler' => $handlerStack]); + $this->client = new Client(['handler' => $handlerStack]); - // Inject mocked client - $this->injectMockedClient($client); - } + // PSR-17 factories (Guzzle's HttpFactory implements both RequestFactory and StreamFactory) + $this->httpFactory = new HttpFactory(); - protected function injectMockedClient(Client $client): void - { - $reflection = new ReflectionClass($this->module); - $property = $reflection->getProperty('client'); - $property->setAccessible(true); - $property->setValue($this->module, $client); + $config = [ + 'host' => '127.0.0.1', + 'port' => 8080, + 'httpClient' => $this->client, + 'requestFactory' => $this->httpFactory, + 'streamFactory' => $this->httpFactory, + ]; - // Also set baseUrl + $container = $this->createMock(ModuleContainer::class); + $this->module = new Wiremock($container); + $this->module->_setConfig($config); + + // Set baseUrl via reflection (needed for initialization without health check) + $reflection = new ReflectionClass($this->module); $baseUrlProperty = $reflection->getProperty('baseUrl'); $baseUrlProperty->setAccessible(true); $baseUrlProperty->setValue($this->module, 'http://127.0.0.1:8080/__admin'); + + // Set PSR clients via reflection + $httpClientProperty = $reflection->getProperty('httpClient'); + $httpClientProperty->setAccessible(true); + $httpClientProperty->setValue($this->module, $this->client); + + $requestFactoryProperty = $reflection->getProperty('requestFactory'); + $requestFactoryProperty->setAccessible(true); + $requestFactoryProperty->setValue($this->module, $this->httpFactory); + + $streamFactoryProperty = $reflection->getProperty('streamFactory'); + $streamFactoryProperty->setAccessible(true); + $streamFactoryProperty->setValue($this->module, $this->httpFactory); } public function testHaveHttpStubForCreatesGetStub(): void