diff --git a/.github/actions/setup-php-composer/action.yml b/.github/actions/setup-php-composer/action.yml index 39cf403..e91d9b1 100644 --- a/.github/actions/setup-php-composer/action.yml +++ b/.github/actions/setup-php-composer/action.yml @@ -5,7 +5,7 @@ inputs: php-version: description: 'PHP version to use' required: false - default: '8.3' + default: '8.4' coverage: description: 'Coverage driver to use (none, xdebug, pcov)' required: false diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b3d8e08..13af43a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,10 +14,10 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: - fail-fast: true + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - php: [8.3, 8.4, 8.5] + php: [8.4, 8.5] stability: [prefer-lowest, prefer-stable] name: PHP${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/README.md b/README.md index cf7c604..7e92f9a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A modern, fluent builder for [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) ## Requirements -- PHP 8.3+ +- PHP 8.4+ ## Installation diff --git a/composer.json b/composer.json index 6a39d87..fe5f565 100644 --- a/composer.json +++ b/composer.json @@ -20,19 +20,22 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "ext-json": "*", "cortexphp/json-schema": "^1.1", "opis/json-schema": "^2.4" }, "require-dev": { - "pestphp/pest": "^4.1.4", - "pestphp/pest-plugin-type-coverage": "^4.0.3", + "pestphp/pest": "^5.0", + "pestphp/pest-plugin-mutate": "^5.0.1", + "pestphp/pest-plugin-phpstan": "^5.0", + "pestphp/pest-plugin-rector": "^5.0", + "pestphp/pest-plugin-type-coverage": "^5.0", "phpstan/phpstan": "^2.1.32", "phpstan/phpstan-strict-rules": "^2.0", "rector/rector": "^2.2", "symfony/yaml": "^6.0 || ^7.0", - "symplify/easy-coding-standard": "13.2.3" + "symplify/easy-coding-standard": "~13.1.0" }, "suggest": { "symfony/yaml": "Required for OpenApi::toYaml() output (^6.0 || ^7.0)." @@ -51,7 +54,7 @@ ] }, "scripts": { - "test": "pest --no-coverage", + "test": "pest --parallel", "ecs": "ecs check --fix", "ecs:check": "ecs check", "rector": "rector process", diff --git a/docs/openapi/installation.mdx b/docs/openapi/installation.mdx index c471221..fbf0d92 100644 --- a/docs/openapi/installation.mdx +++ b/docs/openapi/installation.mdx @@ -6,7 +6,7 @@ icon: 'terminal' ## Requirements -- PHP 8.3+ +- PHP 8.4+ ## Installation diff --git a/docs/openapi/introduction.mdx b/docs/openapi/introduction.mdx index 206da65..97b562f 100644 --- a/docs/openapi/introduction.mdx +++ b/docs/openapi/introduction.mdx @@ -66,7 +66,7 @@ OpenAPI 3.1 aligns fully with **JSON Schema 2020-12**, which means every schema title="Type Safety" icon="shield" > - Built with PHP 8.3+ strict types, backed enums, and named constructors. PHPStan level 10 clean with 100% type coverage. + Built with PHP 8.4+ strict types, backed enums, and named constructors. PHPStan level 10 clean with 100% type coverage. @@ -130,7 +130,7 @@ echo $doc->toJson(JSON_PRETTY_PRINT); ``` -This package requires PHP 8.3 or higher. `symfony/yaml` is an optional runtime dependency — only needed if you call `toYaml()`. +This package requires PHP 8.4 or higher. `symfony/yaml` is an optional runtime dependency — only needed if you call `toYaml()`. ## How It Relates to cortexphp/json-schema diff --git a/ecs.php b/ecs.php index 49dd243..f3e2bd1 100644 --- a/ecs.php +++ b/ecs.php @@ -33,7 +33,7 @@ cleanCode: true, ) ->withPhpCsFixerSets( - php83Migration: true, + php84Migration: true, ) ->withRules([ NotOperatorWithSuccessorSpaceFixer::class, diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 2d71067..c6ff3f2 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,11 +1,11 @@ includes: - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/pestphp/pest-plugin-phpstan/extension.neon parameters: level: 10 paths: - src - stubFiles: - - tests/phpstan/pest-expectations.stub + - tests tmpDir: .phpstan-cache checkBenevolentUnionTypes: true diff --git a/rector.php b/rector.php index 9e47c24..2dd2e5a 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Pest\Rector\Set\PestSetList; use Rector\CodeQuality\Rector\Catch_\ThrowWithPreviousExceptionRector; return RectorConfig::configure() @@ -15,7 +16,12 @@ importDocBlockNames: false, removeUnusedImports: true, ) - ->withPhpSets() + ->withPhpSets( + php84: true, + ) + ->withSets([ + PestSetList::CODING_STYLE, + ]) ->withPreparedSets( deadCode: true, codeQuality: true, diff --git a/src/Concerns/BuildsArray.php b/src/Concerns/BuildsArray.php index 4bb6493..381230b 100644 --- a/src/Concerns/BuildsArray.php +++ b/src/Concerns/BuildsArray.php @@ -47,12 +47,12 @@ protected function buildArray(array $fields, array $alwaysInclude = []): array } if ($this instanceof HasExtensionsInterface) { - foreach ($this->getExtensions() as $extensionKey => $extensionValue) { - $output[$extensionKey] = $extensionValue; + foreach ($this->getExtensions() as $extensionKey => $extensionValue) { // @phpstan-ignore foreach.nonIterable + $output[$extensionKey] = $extensionValue; // @phpstan-ignore offsetAccess.invalidOffset } } - return $output; + return $output; // @phpstan-ignore return.type } private function unwrapValue(mixed $value): mixed diff --git a/src/OpenApi.php b/src/OpenApi.php index 43de68b..5320230 100644 --- a/src/OpenApi.php +++ b/src/OpenApi.php @@ -275,7 +275,7 @@ public function validate(): void return; } - $formatted = (new ErrorFormatter())->format($error); + $formatted = new ErrorFormatter()->format($error); throw new ValidationException( 'OpenAPI document failed meta-schema validation: ' . json_encode($formatted, JSON_UNESCAPED_SLASHES), diff --git a/tests/Expectations.php b/tests/Expectations.php index 501f0c5..3de7e13 100644 --- a/tests/Expectations.php +++ b/tests/Expectations.php @@ -3,19 +3,59 @@ declare(strict_types=1); use Cortex\OpenApi\OpenApi; +use PHPUnit\Framework\Assert; use Cortex\OpenApi\Exceptions\ValidationException; /** - * Custom Pest expectations for OpenAPI meta-schema validation. - * Method signatures for IDE/static analysis: tests/phpstan/pest-expectations.stub - * + * @return array + */ +function expectArray(mixed $value): array +{ + expect($value)->toBeArray(); + + if (! is_array($value)) { + Assert::fail('Expected array.'); + } + + return $value; +} + +/** + * @param array $keys + */ +function keyIndex(array $keys, string $key): int +{ + $index = array_search($key, $keys, true); + expect($index)->toBeInt(); + + if (! is_int($index)) { + Assert::fail("Expected key [{$key}] to exist."); + } + + return $index; +} + +function assertOpenApiValidationPasses(OpenApi $openApi): void +{ + try { + $openApi->validate(); + } catch (ValidationException $validationException) { + Assert::fail( + 'Expected OpenAPI document to pass validation: ' . $validationException->getMessage(), + ); + } + + Assert::assertInstanceOf(OpenApi::class, $openApi); +} + +/** * @param array> $expectedErrors */ function assertOpenApiValidationErrors(OpenApi $openApi, array $expectedErrors): void { try { $openApi->validate(); - test()->fail('Expected ValidationException'); + Assert::fail('Expected ValidationException'); } catch (ValidationException $validationException) { foreach ($expectedErrors as $pointer => $messages) { expect($validationException->errors())->toHaveKey($pointer); @@ -26,42 +66,9 @@ function assertOpenApiValidationErrors(OpenApi $openApi, array $expectedErrors): } } -expect()->extend('toFailOpenApiValidation', function (array $expectedErrors): mixed { - if (! $this->value instanceof OpenApi) { - test()->fail('Expected an OpenApi instance.'); - } - - assertOpenApiValidationErrors($this->value, $expectedErrors); - - return $this; -}); - -expect()->extend('toFailOpenApiValidationAt', function (string $pointer, string $message): mixed { - if (! $this->value instanceof OpenApi) { - test()->fail('Expected an OpenApi instance.'); - } - - assertOpenApiValidationErrors($this->value, [ +function assertOpenApiValidationFailsAt(OpenApi $openApi, string $pointer, string $message): void +{ + assertOpenApiValidationErrors($openApi, [ $pointer => [$message], ]); - - return $this; -}); - -expect()->extend('toPassOpenApiValidation', function (): mixed { - if (! $this->value instanceof OpenApi) { - test()->fail('Expected an OpenApi instance.'); - } - - try { - $this->value->validate(); - } catch (ValidationException $validationException) { - test()->fail( - 'Expected OpenAPI document to pass validation: ' . $validationException->getMessage(), - ); - } - - expect($this->value)->toBeInstanceOf(OpenApi::class); - - return $this; -}); +} diff --git a/tests/Pest.php b/tests/Pest.php index 5802c47..cbe5884 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,4 +4,6 @@ namespace Cortex\OpenApi\Tests; -uses(TestCase::class)->in('Unit'); +pest() + ->extend(TestCase::class) + ->in('Unit'); diff --git a/tests/Unit/Concerns/BuildsArrayTest.php b/tests/Unit/Concerns/BuildsArrayTest.php index af193cf..13732f4 100644 --- a/tests/Unit/Concerns/BuildsArrayTest.php +++ b/tests/Unit/Concerns/BuildsArrayTest.php @@ -55,7 +55,7 @@ public function assemble(array $fields): array } it('drops null fields', function (): void { - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'title' => 'X', 'version' => null, ]); @@ -66,7 +66,7 @@ public function assemble(array $fields): array }); it('drops empty arrays', function (): void { - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'title' => 'X', 'tags' => [], ]); @@ -77,7 +77,7 @@ public function assemble(array $fields): array }); it('unwraps a Serializable child', function (): void { - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'info' => new BuildsArraySerializableFixture(), ]); @@ -89,7 +89,7 @@ public function assemble(array $fields): array }); it('unwraps a list of Serializable children preserving list semantics', function (): void { - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'tags' => [ new BuildsArraySerializableFixture(), new BuildsArraySerializableFixture(), @@ -109,7 +109,7 @@ public function assemble(array $fields): array }); it('unwraps an associative array of Serializable children preserving keys', function (): void { - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'paths' => [ '/users' => new BuildsArraySerializableFixture(), '/pets' => new BuildsArraySerializableFixture(), @@ -143,7 +143,7 @@ public function assemble(array $fields): array }); it('preserves explicit false values', function (): void { - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'deprecated' => false, ]); @@ -153,7 +153,7 @@ public function assemble(array $fields): array }); it('preserves explicit zero values', function (): void { - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'minimum' => 0, ]); @@ -181,10 +181,10 @@ public function assemble(array $fields): array // Reference: stand-alone toArray() would include both $schema URI and title. $standalone = $stringSchema->toArray(); - expect($standalone)->toHaveKey('$schema'); - expect($standalone)->toHaveKey('title', 'IgnoredTitle'); + expect($standalone)->toHaveKey('$schema') + ->toHaveKey('title', 'IgnoredTitle'); - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'schema' => $stringSchema, ]); @@ -202,7 +202,7 @@ public function assemble(array $fields): array expect($objectSchema->getTitle())->toBe('consults') ->and($objectSchema->getInitialTitle())->toBe('Consult'); - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'schema' => $objectSchema, ]); @@ -211,8 +211,8 @@ public function assemble(array $fields): array 'type' => 'object', 'title' => 'consults', ], - ]); - expect($out['schema'])->not->toHaveKey('$schema'); + ]) + ->and($out['schema'])->not->toHaveKey('$schema'); }); it('keeps a JsonSchema title set on a schema that had no constructor title', function (): void { @@ -221,7 +221,7 @@ public function assemble(array $fields): array expect($stringSchema->getTitle())->toBe('IsoDateTime') ->and($stringSchema->getInitialTitle())->toBeNull(); - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'schema' => $stringSchema, ]); @@ -236,7 +236,7 @@ public function assemble(array $fields): array it('strips a JsonSchema title that merely restates the constructor argument', function (): void { $stringSchema = Schema::string('IsoDateTime')->title('IsoDateTime'); - $out = (new BuildsArrayFixture())->assemble([ + $out = new BuildsArrayFixture()->assemble([ 'schema' => $stringSchema, ]); diff --git a/tests/Unit/Concerns/HasExtensionsTest.php b/tests/Unit/Concerns/HasExtensionsTest.php index 9c020ea..349c7ef 100644 --- a/tests/Unit/Concerns/HasExtensionsTest.php +++ b/tests/Unit/Concerns/HasExtensionsTest.php @@ -6,9 +6,6 @@ covers(HasExtensions::class); -/** - * Fixture consumer used purely to exercise the HasExtensions trait. - */ final class HasExtensionsFixture { use HasExtensions; @@ -65,5 +62,5 @@ final class HasExtensionsFixture }); it('rejects an empty key', function (): void { - (new HasExtensionsFixture())->x(''); + new HasExtensionsFixture()->x(''); })->throws(InvalidArgumentException::class); diff --git a/tests/Unit/IntegrationTest.php b/tests/Unit/IntegrationTest.php index e307c7e..77d019a 100644 --- a/tests/Unit/IntegrationTest.php +++ b/tests/Unit/IntegrationTest.php @@ -131,14 +131,32 @@ function buildPetstore(): OpenApi $openApi->validate(); $arr = $openApi->toArray(); - - expect($arr['openapi'])->toBe('3.1.0'); - expect($arr['info']['title'])->toBe('Swagger Petstore'); - expect($arr['x-api-id'])->toBe('petstore-1'); - expect($arr['paths']['/pets']['get']['operationId'])->toBe('listPets'); - expect($arr['paths']['/pets']['post']['callbacks']['onPetCreate'])->toHaveKey('{$request.body#/webhookUrl}'); - expect($arr['webhooks']['pet.deleted']['post']['operationId'])->toBe('petDeletedWebhook'); - expect($arr['components']['securitySchemes']['OAuth2']['type'])->toBe('oauth2'); + $info = expectArray($arr['info']); + $paths = expectArray($arr['paths']); + $pets = expectArray($paths['/pets']); + $get = expectArray($pets['get']); + $post = expectArray($pets['post']); + $callbacks = expectArray($post['callbacks']); + $webhooks = expectArray($arr['webhooks']); + $petDeleted = expectArray($webhooks['pet.deleted']); + $petDeletedPost = expectArray($petDeleted['post']); + $components = expectArray($arr['components']); + $securitySchemes = expectArray($components['securitySchemes']); + $oauth2 = expectArray($securitySchemes['OAuth2']); + + expect($arr['openapi'])->toBe('3.1.0') + ->and($info['title']) + ->toBe('Swagger Petstore') + ->and($arr['x-api-id']) + ->toBe('petstore-1') + ->and($get['operationId']) + ->toBe('listPets') + ->and($callbacks['onPetCreate']) + ->toHaveKey('{$request.body#/webhookUrl}') + ->and($petDeletedPost['operationId']) + ->toBe('petDeletedWebhook') + ->and($oauth2['type']) + ->toBe('oauth2'); }); it('round-trips through JSON encoding', function (): void { @@ -171,16 +189,22 @@ function buildPetstore(): OpenApi ), ); - $inline = $openApi->toArray()['paths']['/foo']['get']['responses']['200']['content']['application/json']['schema']; - - expect($inline)->not->toHaveKey('$schema'); - expect($inline)->not->toHaveKey('title'); - expect($inline)->toBe([ - 'type' => 'object', - 'properties' => [ - 'bar' => [ - 'type' => 'string', + $paths = expectArray($openApi->toArray()['paths']); + $foo = expectArray($paths['/foo']); + $get = expectArray($foo['get']); + $responses = expectArray($get['responses']); + $ok = expectArray($responses['200']); + $content = expectArray($ok['content']); + $json = expectArray($content['application/json']); + $inline = $json['schema']; + + expect($inline)->not->toHaveKey('$schema')->not->toHaveKey('title') + ->toBe([ + 'type' => 'object', + 'properties' => [ + 'bar' => [ + 'type' => 'string', + ], ], - ], - ]); + ]); }); diff --git a/tests/Unit/Objects/ComponentsTest.php b/tests/Unit/Objects/ComponentsTest.php index fa1b1e2..d62fcd8 100644 --- a/tests/Unit/Objects/ComponentsTest.php +++ b/tests/Unit/Objects/ComponentsTest.php @@ -21,7 +21,8 @@ covers(Components::class); it('emits nothing by default', function (): void { - expect(Components::create()->toArray())->toBe([]); + expect(Components::create()->toArray()) + ->toBeEmpty(); }); it('registers schemas', function (): void { diff --git a/tests/Unit/Objects/ExampleTest.php b/tests/Unit/Objects/ExampleTest.php index 8ce7296..952fb30 100644 --- a/tests/Unit/Objects/ExampleTest.php +++ b/tests/Unit/Objects/ExampleTest.php @@ -7,7 +7,8 @@ covers(Example::class); it('emits an empty array when no fields are set', function (): void { - expect(Example::create()->toArray())->toBe([]); + expect(Example::create()->toArray()) + ->toBeEmpty(); }); it('emits every field when set', function (): void { @@ -52,5 +53,6 @@ $example->clearValue(); - expect($example->toArray())->toBe([]); + expect($example->toArray()) + ->toBeEmpty(); }); diff --git a/tests/Unit/Objects/ExternalDocsTest.php b/tests/Unit/Objects/ExternalDocsTest.php index 9c37c38..eb7eca2 100644 --- a/tests/Unit/Objects/ExternalDocsTest.php +++ b/tests/Unit/Objects/ExternalDocsTest.php @@ -9,12 +9,11 @@ it('emits url and optional description', function (): void { expect(ExternalDocs::create('https://example.com/docs')->toArray())->toBe([ 'url' => 'https://example.com/docs', - ]); - - expect( - ExternalDocs::create('https://example.com/docs')->description('More info')->toArray(), - )->toBe([ - 'url' => 'https://example.com/docs', - 'description' => 'More info', - ]); + ]) + ->and(ExternalDocs::create('https://example.com/docs')->description('More info') + ->toArray()) + ->toBe([ + 'url' => 'https://example.com/docs', + 'description' => 'More info', + ]); }); diff --git a/tests/Unit/Objects/HeaderTest.php b/tests/Unit/Objects/HeaderTest.php index 78f4a3d..4794770 100644 --- a/tests/Unit/Objects/HeaderTest.php +++ b/tests/Unit/Objects/HeaderTest.php @@ -9,7 +9,8 @@ covers(Header::class); it('emits nothing by default', function (): void { - expect(Header::create()->toArray())->toBe([]); + expect(Header::create()->toArray()) + ->toBeEmpty(); }); it('emits a schema-based header', function (): void { diff --git a/tests/Unit/Objects/LinkTest.php b/tests/Unit/Objects/LinkTest.php index 294ade0..3c94e2f 100644 --- a/tests/Unit/Objects/LinkTest.php +++ b/tests/Unit/Objects/LinkTest.php @@ -8,7 +8,8 @@ covers(Link::class); it('emits nothing when empty', function (): void { - expect(Link::create()->toArray())->toBe([]); + expect(Link::create()->toArray()) + ->toBeEmpty(); }); it('emits operationId-based link', function (): void { @@ -65,13 +66,14 @@ $array = $link->toArray(); - expect($array)->toHaveKey('requestBody'); - expect($array['requestBody'])->toBe([ - 'key' => 'value', - ]); + expect($array)->toHaveKey('requestBody') + ->and($array['requestBody']) + ->toBe([ + 'key' => 'value', + ]); $keys = array_keys($array); - expect(array_search('requestBody', $keys, true))->toBeGreaterThan(array_search('parameters', $keys, true)); + expect(keyIndex($keys, 'requestBody'))->toBeGreaterThan(keyIndex($keys, 'parameters')); }); it('inserts requestBody before description when no parameters present', function (): void { @@ -84,7 +86,7 @@ expect($array)->toHaveKey('requestBody'); $keys = array_keys($array); - expect(array_search('requestBody', $keys, true))->toBeLessThan(array_search('description', $keys, true)); + expect(keyIndex($keys, 'requestBody'))->toBeLessThan(keyIndex($keys, 'description')); }); it('inserts requestBody before server when no parameters or description', function (): void { @@ -99,7 +101,7 @@ expect($array)->toHaveKey('requestBody'); $keys = array_keys($array); - expect(array_search('requestBody', $keys, true))->toBeLessThan(array_search('server', $keys, true)); + expect(keyIndex($keys, 'requestBody'))->toBeLessThan(keyIndex($keys, 'server')); }); it('appends requestBody at end when no parameters, description, or server', function (): void { @@ -111,6 +113,7 @@ $array = $link->toArray(); - expect($array)->toHaveKey('requestBody'); - expect(array_key_last($array))->toBe('requestBody'); + expect($array)->toHaveKey('requestBody') + ->and(array_key_last($array)) + ->toBe('requestBody'); }); diff --git a/tests/Unit/Objects/MediaTypeTest.php b/tests/Unit/Objects/MediaTypeTest.php index bae6d4b..c0698a0 100644 --- a/tests/Unit/Objects/MediaTypeTest.php +++ b/tests/Unit/Objects/MediaTypeTest.php @@ -11,14 +11,28 @@ covers(MediaType::class); it('knows its own content type', function (): void { - expect(MediaType::json()->getContentType())->toBe('application/json'); - expect(MediaType::xml()->getContentType())->toBe('application/xml'); - expect(MediaType::form()->getContentType())->toBe('application/x-www-form-urlencoded'); - expect(MediaType::multipart()->getContentType())->toBe('multipart/form-data'); - expect(MediaType::text()->getContentType())->toBe('text/plain'); - expect(MediaType::html()->getContentType())->toBe('text/html'); - expect(MediaType::octetStream()->getContentType())->toBe('application/octet-stream'); - expect(MediaType::of('application/pdf')->getContentType())->toBe('application/pdf'); + expect(MediaType::json()->getContentType())->toBe('application/json') + ->and(MediaType::xml() + ->getContentType()) + ->toBe('application/xml') + ->and(MediaType::form() + ->getContentType()) + ->toBe('application/x-www-form-urlencoded') + ->and(MediaType::multipart() + ->getContentType()) + ->toBe('multipart/form-data') + ->and(MediaType::text() + ->getContentType()) + ->toBe('text/plain') + ->and(MediaType::html() + ->getContentType()) + ->toBe('text/html') + ->and(MediaType::octetStream() + ->getContentType()) + ->toBe('application/octet-stream') + ->and(MediaType::of('application/pdf') + ->getContentType()) + ->toBe('application/pdf'); }); it('accepts a JsonSchema and strips $schema/title when serialized', function (): void { @@ -91,7 +105,7 @@ ]); $keys = array_keys($mediaType->toArray()); - expect(array_search('example', $keys, true))->toBeLessThan(array_search('encoding', $keys, true)); + expect(keyIndex($keys, 'example'))->toBeLessThan(keyIndex($keys, 'encoding')); }); it('example appears at end when only schema present', function (): void { diff --git a/tests/Unit/Objects/OAuthFlowsTest.php b/tests/Unit/Objects/OAuthFlowsTest.php index 4e9ae86..346c7d4 100644 --- a/tests/Unit/Objects/OAuthFlowsTest.php +++ b/tests/Unit/Objects/OAuthFlowsTest.php @@ -8,7 +8,8 @@ covers(OAuthFlows::class, OAuthFlow::class); it('emits nothing by default', function (): void { - expect(OAuthFlows::create()->toArray())->toBe([]); + expect(OAuthFlows::create()->toArray()) + ->toBeEmpty(); }); it('emits all four flows when configured', function (): void { diff --git a/tests/Unit/Objects/OperationTest.php b/tests/Unit/Objects/OperationTest.php index ba02f7a..eea1775 100644 --- a/tests/Unit/Objects/OperationTest.php +++ b/tests/Unit/Objects/OperationTest.php @@ -20,18 +20,33 @@ covers(Operation::class); it('knows its HTTP method', function (): void { - expect(Operation::get()->getMethod())->toBe(HttpMethod::Get); - expect(Operation::post()->getMethod())->toBe(HttpMethod::Post); - expect(Operation::put()->getMethod())->toBe(HttpMethod::Put); - expect(Operation::patch()->getMethod())->toBe(HttpMethod::Patch); - expect(Operation::delete()->getMethod())->toBe(HttpMethod::Delete); - expect(Operation::options()->getMethod())->toBe(HttpMethod::Options); - expect(Operation::head()->getMethod())->toBe(HttpMethod::Head); - expect(Operation::trace()->getMethod())->toBe(HttpMethod::Trace); + expect(Operation::get()->getMethod())->toBe(HttpMethod::Get) + ->and(Operation::post() + ->getMethod()) + ->toBe(HttpMethod::Post) + ->and(Operation::put() + ->getMethod()) + ->toBe(HttpMethod::Put) + ->and(Operation::patch() + ->getMethod()) + ->toBe(HttpMethod::Patch) + ->and(Operation::delete() + ->getMethod()) + ->toBe(HttpMethod::Delete) + ->and(Operation::options() + ->getMethod()) + ->toBe(HttpMethod::Options) + ->and(Operation::head() + ->getMethod()) + ->toBe(HttpMethod::Head) + ->and(Operation::trace() + ->getMethod()) + ->toBe(HttpMethod::Trace); }); it('emits nothing by default', function (): void { - expect(Operation::get()->toArray())->toBe([]); + expect(Operation::get()->toArray()) + ->toBeEmpty(); }); it('emits tags/summary/description/operationId/deprecated', function (): void { @@ -182,6 +197,6 @@ ->callback('onError', Callback::ref('OnError')); $arr = $operation->toArray(); - expect($arr['callbacks'])->toHaveKey('onData'); - expect($arr['callbacks'])->toHaveKey('onError'); + expect($arr['callbacks'])->toHaveKey('onData') + ->toHaveKey('onError'); }); diff --git a/tests/Unit/Objects/PathItemTest.php b/tests/Unit/Objects/PathItemTest.php index 1359a52..9eec251 100644 --- a/tests/Unit/Objects/PathItemTest.php +++ b/tests/Unit/Objects/PathItemTest.php @@ -89,15 +89,17 @@ ); $result = $pathItem->toArray(); - expect($result['servers'])->toBe([ + $servers = expectArray($result['servers']); + expect($servers)->toBe([ [ 'url' => 'https://api.example.com', ], [ 'url' => 'https://staging.example.com', ], - ]); - expect(array_is_list($result['servers']))->toBeTrue(); + ]) + ->and($servers) + ->toBeList(); }); it('parameters() preserves values as a list', function (): void { @@ -108,6 +110,9 @@ ); $result = $pathItem->toArray(); - expect(array_is_list($result['parameters']))->toBeTrue(); - expect($result['parameters'])->toHaveCount(2); + $parameters = expectArray($result['parameters']); + expect($parameters) + ->toBeList() + ->and($parameters) + ->toHaveCount(2); }); diff --git a/tests/Unit/Objects/ReferenceTest.php b/tests/Unit/Objects/ReferenceTest.php index 2013d83..cd5fd79 100644 --- a/tests/Unit/Objects/ReferenceTest.php +++ b/tests/Unit/Objects/ReferenceTest.php @@ -3,14 +3,12 @@ declare(strict_types=1); use Cortex\OpenApi\Objects\Reference; -use Cortex\OpenApi\Contracts\Serializable; covers(Reference::class); it('builds a basic $ref array', function (): void { $reference = Reference::to('#/components/schemas/User'); - expect($reference)->toBeInstanceOf(Serializable::class); expect($reference->toArray())->toBe([ '$ref' => '#/components/schemas/User', ]); @@ -45,41 +43,50 @@ it('provides typed shortcuts for every component bucket', function (): void { expect(Reference::schema('User')->toArray())->toBe([ '$ref' => '#/components/schemas/User', - ]); - - expect(Reference::response('NotFound')->toArray())->toBe([ - '$ref' => '#/components/responses/NotFound', - ]); - - expect(Reference::parameter('PetId')->toArray())->toBe([ - '$ref' => '#/components/parameters/PetId', - ]); - - expect(Reference::requestBody('CreateUser')->toArray())->toBe([ - '$ref' => '#/components/requestBodies/CreateUser', - ]); - - expect(Reference::header('RateLimit')->toArray())->toBe([ - '$ref' => '#/components/headers/RateLimit', - ]); - - expect(Reference::example('Sample')->toArray())->toBe([ - '$ref' => '#/components/examples/Sample', - ]); - - expect(Reference::link('NextPage')->toArray())->toBe([ - '$ref' => '#/components/links/NextPage', - ]); - - expect(Reference::callback('OnCreate')->toArray())->toBe([ - '$ref' => '#/components/callbacks/OnCreate', - ]); - - expect(Reference::securityScheme('BearerAuth')->toArray())->toBe([ - '$ref' => '#/components/securitySchemes/BearerAuth', - ]); - - expect(Reference::pathItem('UserById')->toArray())->toBe([ - '$ref' => '#/components/pathItems/UserById', - ]); + ]) + ->and(Reference::response('NotFound') + ->toArray()) + ->toBe([ + '$ref' => '#/components/responses/NotFound', + ]) + ->and(Reference::parameter('PetId') + ->toArray()) + ->toBe([ + '$ref' => '#/components/parameters/PetId', + ]) + ->and(Reference::requestBody('CreateUser') + ->toArray()) + ->toBe([ + '$ref' => '#/components/requestBodies/CreateUser', + ]) + ->and(Reference::header('RateLimit') + ->toArray()) + ->toBe([ + '$ref' => '#/components/headers/RateLimit', + ]) + ->and(Reference::example('Sample') + ->toArray()) + ->toBe([ + '$ref' => '#/components/examples/Sample', + ]) + ->and(Reference::link('NextPage') + ->toArray()) + ->toBe([ + '$ref' => '#/components/links/NextPage', + ]) + ->and(Reference::callback('OnCreate') + ->toArray()) + ->toBe([ + '$ref' => '#/components/callbacks/OnCreate', + ]) + ->and(Reference::securityScheme('BearerAuth') + ->toArray()) + ->toBe([ + '$ref' => '#/components/securitySchemes/BearerAuth', + ]) + ->and(Reference::pathItem('UserById') + ->toArray()) + ->toBe([ + '$ref' => '#/components/pathItems/UserById', + ]); }); diff --git a/tests/Unit/Objects/RequestBodyTest.php b/tests/Unit/Objects/RequestBodyTest.php index c1f6456..3474e1b 100644 --- a/tests/Unit/Objects/RequestBodyTest.php +++ b/tests/Unit/Objects/RequestBodyTest.php @@ -90,7 +90,8 @@ it('json() accepts a Reference', function (): void { $requestBody = RequestBody::create()->json(Reference::schema('CreateUser')); - expect($requestBody->toArray()['content']['application/json'])->toBe([ + $content = expectArray($requestBody->toArray()['content']); + expect($content['application/json'])->toBe([ 'schema' => [ '$ref' => '#/components/schemas/CreateUser', ], diff --git a/tests/Unit/Objects/ResponseTest.php b/tests/Unit/Objects/ResponseTest.php index cd716bc..11ff408 100644 --- a/tests/Unit/Objects/ResponseTest.php +++ b/tests/Unit/Objects/ResponseTest.php @@ -12,37 +12,42 @@ covers(Response::class); it('named 200 response uses "OK" description by default', function (): void { - expect(Response::ok()->getStatusCode())->toBe('200'); - expect(Response::ok()->toArray())->toBe([ - 'description' => 'OK', - ]); + expect(Response::ok()->getStatusCode())->toBe('200') + ->and(Response::ok() + ->toArray()) + ->toBe([ + 'description' => 'OK', + ]); }); it('named 404 response', function (): void { $response = Response::notFound(); - expect($response->getStatusCode())->toBe('404'); - expect($response->toArray())->toBe([ - 'description' => 'Not Found', - ]); + expect($response->getStatusCode())->toBe('404') + ->and($response->toArray()) + ->toBe([ + 'description' => 'Not Found', + ]); }); it('arbitrary status via status()', function (): void { $response = Response::status(418)->description("I'm a teapot"); - expect($response->getStatusCode())->toBe('418'); - expect($response->toArray())->toBe([ - 'description' => "I'm a teapot", - ]); + expect($response->getStatusCode())->toBe('418') + ->and($response->toArray()) + ->toBe([ + 'description' => "I'm a teapot", + ]); }); it('default response uses the default key', function (): void { $response = Response::default()->description('Unexpected error'); - expect($response->getStatusCode())->toBe('default'); - expect($response->toArray())->toBe([ - 'description' => 'Unexpected error', - ]); + expect($response->getStatusCode())->toBe('default') + ->and($response->toArray()) + ->toBe([ + 'description' => 'Unexpected error', + ]); }); it('emits content, headers, and links', function (): void { @@ -125,7 +130,8 @@ it('json() accepts a Reference', function (): void { $response = Response::notFound()->json(Reference::schema('Error')); - expect($response->toArray()['content']['application/json'])->toBe([ + $content = expectArray($response->toArray()['content']); + expect($content['application/json'])->toBe([ 'schema' => [ '$ref' => '#/components/schemas/Error', ], @@ -133,6 +139,7 @@ }); it('json() with no schema emits an empty application/json key', function (): void { - expect(Response::ok()->json()->toArray())->toHaveKey('content'); - expect(Response::ok()->json()->toArray()['content'])->toHaveKey('application/json'); + expect(Response::ok()->json()->toArray())->toHaveKey('content') + ->and(Response::ok()->json()->toArray()['content']) + ->toHaveKey('application/json'); }); diff --git a/tests/Unit/Objects/SecurityRequirementTest.php b/tests/Unit/Objects/SecurityRequirementTest.php index 06d72be..6051999 100644 --- a/tests/Unit/Objects/SecurityRequirementTest.php +++ b/tests/Unit/Objects/SecurityRequirementTest.php @@ -19,5 +19,6 @@ }); it('builds an empty requirement (public access)', function (): void { - expect(SecurityRequirement::public()->toArray())->toBe([]); + expect(SecurityRequirement::public()->toArray()) + ->toBeEmpty(); }); diff --git a/tests/Unit/Objects/XmlTest.php b/tests/Unit/Objects/XmlTest.php index e38f697..7127b37 100644 --- a/tests/Unit/Objects/XmlTest.php +++ b/tests/Unit/Objects/XmlTest.php @@ -7,7 +7,8 @@ covers(Xml::class); it('emits nothing by default', function (): void { - expect(Xml::create()->toArray())->toBe([]); + expect(Xml::create()->toArray()) + ->toBeEmpty(); }); it('emits every field', function (): void { diff --git a/tests/Unit/OpenApiTest.php b/tests/Unit/OpenApiTest.php index 87d2092..ccbe999 100644 --- a/tests/Unit/OpenApiTest.php +++ b/tests/Unit/OpenApiTest.php @@ -120,8 +120,10 @@ }); it('knows its OpenAPI version enum', function (): void { - expect(OpenApi::create(OpenApiVersion::V3_1_0)->getVersion())->toBe(OpenApiVersion::V3_1_0); - expect(OpenApi::create(OpenApiVersion::V3_1_1)->getVersion())->toBe(OpenApiVersion::V3_1_1); + expect(OpenApi::create(OpenApiVersion::V3_1_0)->getVersion())->toBe(OpenApiVersion::V3_1_0) + ->and(OpenApi::create(OpenApiVersion::V3_1_1) + ->getVersion()) + ->toBe(OpenApiVersion::V3_1_1); }); it('supports vendor extensions at the root', function (): void { @@ -129,7 +131,8 @@ ->info(Info::create('x', '1')) ->x('x-internal', true); - expect($openApi->toArray()['x-internal'])->toBe(true); + expect($openApi->toArray()['x-internal']) + ->toBeTrue(); }); it('path() adds a PathItem or Reference by explicit pattern', function (): void { @@ -140,12 +143,12 @@ )) ->path('/legacy', Reference::pathItem('LegacyUsers')); - $arr = $openApi->toArray(); - expect($arr['paths'])->toHaveKey('/users'); - expect($arr['paths'])->toHaveKey('/legacy'); - expect($arr['paths']['/legacy'])->toBe([ - '$ref' => '#/components/pathItems/LegacyUsers', - ]); + $paths = expectArray($openApi->toArray()['paths']); + expect($paths)->toHaveKeys(['/users', '/legacy']) + ->and($paths['/legacy']) + ->toBe([ + '$ref' => '#/components/pathItems/LegacyUsers', + ]); }); it('adds webhooks one at a time with webhook()', function (): void { diff --git a/tests/Unit/Validation/ComponentsTest.php b/tests/Unit/Validation/ComponentsTest.php index 9ec5736..5123773 100644 --- a/tests/Unit/Validation/ComponentsTest.php +++ b/tests/Unit/Validation/ComponentsTest.php @@ -16,7 +16,8 @@ Components::create()->schema('Invalid Key!', Schema::object()), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/components/schemas', 'The string should match pattern: ^[a-zA-Z0-9._-]+$', ); diff --git a/tests/Unit/Validation/DocumentTest.php b/tests/Unit/Validation/DocumentTest.php index bd92979..426a005 100644 --- a/tests/Unit/Validation/DocumentTest.php +++ b/tests/Unit/Validation/DocumentTest.php @@ -24,7 +24,7 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('accepts a minimal valid 3.1.1 document', function (): void { @@ -36,7 +36,7 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('accepts a document with components, tags, and schemas', function (): void { @@ -52,13 +52,14 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('rejects a document missing info', function (): void { $openApi = OpenApi::create(); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/', 'The required properties (info) are missing', ); @@ -69,7 +70,7 @@ ->info(Info::create('x', '1.0.0')) ->components(Components::create()->schema('User', Schema::object())); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('accepts a webhooks-only document without paths or components', function (): void { @@ -81,14 +82,14 @@ ), ]); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('rejects a document with info but no paths, components, or webhooks', function (): void { $openApi = OpenApi::create() ->info(Info::create('x', '1.0.0')); - expect($openApi)->toFailOpenApiValidation([ + assertOpenApiValidationErrors($openApi, [ '/' => [ 'The required properties (paths) are missing', 'The required properties (components) are missing', diff --git a/tests/Unit/Validation/ExampleTest.php b/tests/Unit/Validation/ExampleTest.php index 98b4294..db623f3 100644 --- a/tests/Unit/Validation/ExampleTest.php +++ b/tests/Unit/Validation/ExampleTest.php @@ -21,7 +21,8 @@ ->externalValue('https://example.com/examples/user.json')), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/components/examples/Bad', 'The data must not match schema', ); diff --git a/tests/Unit/Validation/LicenseTest.php b/tests/Unit/Validation/LicenseTest.php index 9ea419f..09c9025 100644 --- a/tests/Unit/Validation/LicenseTest.php +++ b/tests/Unit/Validation/LicenseTest.php @@ -20,7 +20,8 @@ ) ->components(Components::create()->schema('Empty', Schema::object())); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/info/license', 'The data must not match schema', ); diff --git a/tests/Unit/Validation/ParameterTest.php b/tests/Unit/Validation/ParameterTest.php index 3e02bb6..8372fec 100644 --- a/tests/Unit/Validation/ParameterTest.php +++ b/tests/Unit/Validation/ParameterTest.php @@ -28,7 +28,7 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('rejects a parameter with neither schema nor content', function (): void { @@ -42,7 +42,7 @@ ), ); - expect($openApi)->toFailOpenApiValidation([ + assertOpenApiValidationErrors($openApi, [ '/paths/~1ping/get/parameters/0' => [ 'The required properties (schema) are missing', 'The required properties (content) are missing', @@ -61,7 +61,8 @@ ), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/paths/~1items~1%7Bid%7D/get/parameters/0/required', 'The data must match the const value', ); diff --git a/tests/Unit/Validation/PathsTest.php b/tests/Unit/Validation/PathsTest.php index 4e97f76..54f652d 100644 --- a/tests/Unit/Validation/PathsTest.php +++ b/tests/Unit/Validation/PathsTest.php @@ -17,7 +17,8 @@ Operation::get()->responses(Response::ok()), )); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/paths', 'Unevaluated object properties not allowed: noslash', ); diff --git a/tests/Unit/Validation/RequestBodyTest.php b/tests/Unit/Validation/RequestBodyTest.php index 2f378ee..12caadd 100644 --- a/tests/Unit/Validation/RequestBodyTest.php +++ b/tests/Unit/Validation/RequestBodyTest.php @@ -24,7 +24,7 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('rejects a requestBody with no content', function (): void { @@ -36,7 +36,8 @@ Components::create()->requestBody('Body', RequestBody::create()), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/components/requestBodies/Body', 'The data (array) must match the type: object', ); diff --git a/tests/Unit/Validation/ResponseTest.php b/tests/Unit/Validation/ResponseTest.php index f139f3b..9ce7992 100644 --- a/tests/Unit/Validation/ResponseTest.php +++ b/tests/Unit/Validation/ResponseTest.php @@ -23,7 +23,8 @@ ), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/paths/~1ping/get/responses', 'Unevaluated object properties not allowed: 99', ); @@ -42,7 +43,7 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('rejects a response object missing its description', function (): void { @@ -55,7 +56,8 @@ ), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/paths/~1ping/get/responses/503', 'The data (array) must match the type: object', ); @@ -74,7 +76,7 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('rejects a link with neither operationRef nor operationId', function (): void { @@ -88,7 +90,8 @@ ), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/paths/~1ping/get/responses/200/links/Empty', 'The data (array) must match the type: object', ); diff --git a/tests/Unit/Validation/SecuritySchemeTest.php b/tests/Unit/Validation/SecuritySchemeTest.php index 3a89298..a2aa499 100644 --- a/tests/Unit/Validation/SecuritySchemeTest.php +++ b/tests/Unit/Validation/SecuritySchemeTest.php @@ -36,7 +36,7 @@ ), ); - expect($openApi)->toPassOpenApiValidation(); + assertOpenApiValidationPasses($openApi); }); it('rejects an oauth2 security scheme without flows', function (): void { @@ -47,7 +47,8 @@ ->securityScheme('oauth2', SecurityScheme::oauth2()), ); - expect($openApi)->toFailOpenApiValidationAt( + assertOpenApiValidationFailsAt( + $openApi, '/components/securitySchemes/oauth2', 'The required properties (flows) are missing', ); diff --git a/tests/Unit/Validation/ValidationExceptionTest.php b/tests/Unit/Validation/ValidationExceptionTest.php index 2c1beed..fbb9d27 100644 --- a/tests/Unit/Validation/ValidationExceptionTest.php +++ b/tests/Unit/Validation/ValidationExceptionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Cortex\OpenApi\OpenApi; +use PHPUnit\Framework\Assert; use Cortex\OpenApi\Exceptions\ValidationException; covers(OpenApi::class); @@ -12,7 +13,7 @@ try { $openApi->validate(); - $this->fail('Expected ValidationException'); + Assert::fail('Expected ValidationException'); } catch (ValidationException $validationException) { expect($validationException->getMessage())->toContain('OpenAPI document failed meta-schema validation:'); } @@ -39,18 +40,18 @@ try { $openApi->validate(); - expect(true)->toBeFalse('Expected ValidationException'); + Assert::fail('Expected ValidationException'); } catch (ValidationException $validationException) { $errors = $validationException->errors(); - expect($errors)->toBeArray(); - expect($errors)->not->toBeEmpty(); - expect($errors)->toHaveKey('/'); - expect($errors['/'])->toBeArray(); - expect($errors['/'][0])->toBe('The required properties (info) are missing'); + expect($errors)->not->toBeEmpty() + ->toHaveKey('/'); + $rootErrors = expectArray($errors['/']); + expect($rootErrors[0])->toBe('The required properties (info) are missing'); } }); it('ValidationException constructed without errors returns empty array from errors()', function (): void { $e = new ValidationException('Something went wrong'); - expect($e->errors())->toBe([]); + expect($e->errors()) + ->toBeEmpty(); }); diff --git a/tests/phpstan/pest-expectations.stub b/tests/phpstan/pest-expectations.stub deleted file mode 100644 index cf8d9aa..0000000 --- a/tests/phpstan/pest-expectations.stub +++ /dev/null @@ -1,18 +0,0 @@ - toFailOpenApiValidation(array> $expectedErrors) - * @method self toFailOpenApiValidationAt(string $pointer, string $message) - * @method self toPassOpenApiValidation() - */ -class Expectation -{ -}