From 58c4836832267cbcf0fc8e89617dbc89bae8a891 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Wed, 17 Jun 2026 09:15:18 +0100 Subject: [PATCH] test: Improve validation coverage --- composer.json | 5 +- phpstan.dist.neon | 2 + phpunit.dist.xml | 5 - tests/Expectations.php | 67 ++++++++++ tests/Unit/Validation/ComponentsTest.php | 23 ++++ tests/Unit/Validation/DocumentTest.php | 98 ++++++++++++++ tests/Unit/Validation/ExampleTest.php | 28 ++++ tests/Unit/Validation/LicenseTest.php | 27 ++++ tests/Unit/Validation/ParameterTest.php | 68 ++++++++++ tests/Unit/Validation/PathsTest.php | 24 ++++ tests/Unit/Validation/RequestBodyTest.php | 43 +++++++ tests/Unit/Validation/ResponseTest.php | 95 ++++++++++++++ tests/Unit/Validation/SecuritySchemeTest.php | 54 ++++++++ .../Validation/ValidationExceptionTest.php | 56 ++++++++ tests/Unit/ValidationTest.php | 120 ------------------ tests/phpstan/pest-expectations.stub | 18 +++ 16 files changed, 607 insertions(+), 126 deletions(-) create mode 100644 tests/Expectations.php create mode 100644 tests/Unit/Validation/ComponentsTest.php create mode 100644 tests/Unit/Validation/DocumentTest.php create mode 100644 tests/Unit/Validation/ExampleTest.php create mode 100644 tests/Unit/Validation/LicenseTest.php create mode 100644 tests/Unit/Validation/ParameterTest.php create mode 100644 tests/Unit/Validation/PathsTest.php create mode 100644 tests/Unit/Validation/RequestBodyTest.php create mode 100644 tests/Unit/Validation/ResponseTest.php create mode 100644 tests/Unit/Validation/SecuritySchemeTest.php create mode 100644 tests/Unit/Validation/ValidationExceptionTest.php delete mode 100644 tests/Unit/ValidationTest.php create mode 100644 tests/phpstan/pest-expectations.stub diff --git a/composer.json b/composer.json index fd97455..26eb80c 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,10 @@ "autoload-dev": { "psr-4": { "Cortex\\OpenApi\\Tests\\": "tests/" - } + }, + "files": [ + "tests/Expectations.php" + ] }, "scripts": { "test": "pest --no-coverage", diff --git a/phpstan.dist.neon b/phpstan.dist.neon index e4fcd01..2d71067 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -5,5 +5,7 @@ parameters: level: 10 paths: - src + stubFiles: + - tests/phpstan/pest-expectations.stub tmpDir: .phpstan-cache checkBenevolentUnionTypes: true diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 8a4d3b6..0c12bb9 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -9,11 +9,6 @@ ./tests - - - - - ./src diff --git a/tests/Expectations.php b/tests/Expectations.php new file mode 100644 index 0000000..501f0c5 --- /dev/null +++ b/tests/Expectations.php @@ -0,0 +1,67 @@ +> $expectedErrors + */ +function assertOpenApiValidationErrors(OpenApi $openApi, array $expectedErrors): void +{ + try { + $openApi->validate(); + test()->fail('Expected ValidationException'); + } catch (ValidationException $validationException) { + foreach ($expectedErrors as $pointer => $messages) { + expect($validationException->errors())->toHaveKey($pointer); + foreach ($messages as $message) { + expect($validationException->errors()[$pointer])->toContain($message); + } + } + } +} + +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, [ + $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/Unit/Validation/ComponentsTest.php b/tests/Unit/Validation/ComponentsTest.php new file mode 100644 index 0000000..9ec5736 --- /dev/null +++ b/tests/Unit/Validation/ComponentsTest.php @@ -0,0 +1,23 @@ +info(Info::create('x', '1.0.0')) + ->components( + Components::create()->schema('Invalid Key!', Schema::object()), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/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 new file mode 100644 index 0000000..bd92979 --- /dev/null +++ b/tests/Unit/Validation/DocumentTest.php @@ -0,0 +1,98 @@ +info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/ping')->operations( + Operation::get()->responses(Response::ok()), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('accepts a minimal valid 3.1.1 document', function (): void { + $openApi = OpenApi::create(OpenApiVersion::V3_1_1) + ->info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/ping')->operations( + Operation::get()->responses(Response::ok()), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('accepts a document with components, tags, and schemas', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('Example', '1.0.0')) + ->tags(Tag::create('Users')) + ->components(Components::create()->schema('User', Schema::object()->properties(Schema::string('id')))) + ->paths( + PathItem::create('/users')->operations( + Operation::get()->tags('Users')->responses( + Response::ok()->content(MediaType::json(Schema::string())), + ), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('rejects a document missing info', function (): void { + $openApi = OpenApi::create(); + + expect($openApi)->toFailOpenApiValidationAt( + '/', + 'The required properties (info) are missing', + ); +}); + +it('accepts a components-only document without paths or webhooks', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->components(Components::create()->schema('User', Schema::object())); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('accepts a webhooks-only document without paths or components', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->webhooks([ + 'user.created' => PathItem::create('/user.created')->operations( + Operation::post()->responses(Response::ok()), + ), + ]); + + expect($openApi)->toPassOpenApiValidation(); +}); + +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([ + '/' => [ + 'The required properties (paths) are missing', + 'The required properties (components) are missing', + 'The required properties (webhooks) are missing', + ], + ]); +}); diff --git a/tests/Unit/Validation/ExampleTest.php b/tests/Unit/Validation/ExampleTest.php new file mode 100644 index 0000000..98b4294 --- /dev/null +++ b/tests/Unit/Validation/ExampleTest.php @@ -0,0 +1,28 @@ +info(Info::create('x', '1.0.0')) + ->components( + Components::create() + ->example('Bad', Example::create() + ->value([ + 'id' => 1, + ]) + ->externalValue('https://example.com/examples/user.json')), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/components/examples/Bad', + 'The data must not match schema', + ); +}); diff --git a/tests/Unit/Validation/LicenseTest.php b/tests/Unit/Validation/LicenseTest.php new file mode 100644 index 0000000..9ea419f --- /dev/null +++ b/tests/Unit/Validation/LicenseTest.php @@ -0,0 +1,27 @@ +info( + Info::create('x', '1.0.0') + ->license(License::create('MIT') + ->identifier('MIT') + ->url('https://spdx.org/licenses/MIT.html')), + ) + ->components(Components::create()->schema('Empty', Schema::object())); + + expect($openApi)->toFailOpenApiValidationAt( + '/info/license', + 'The data must not match schema', + ); +}); diff --git a/tests/Unit/Validation/ParameterTest.php b/tests/Unit/Validation/ParameterTest.php new file mode 100644 index 0000000..3e02bb6 --- /dev/null +++ b/tests/Unit/Validation/ParameterTest.php @@ -0,0 +1,68 @@ +info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/items/{id}')->operations( + Operation::get() + ->parameters( + Parameter::path('id', Schema::string()), + Parameter::query('filter', Schema::string()), + Parameter::header('X-Request-Id', Schema::string()), + Parameter::cookie('session', Schema::string()), + ) + ->responses(Response::ok()), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('rejects a parameter with neither schema nor content', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/ping')->operations( + Operation::get() + ->parameters(Parameter::query('q')) + ->responses(Response::ok()), + ), + ); + + expect($openApi)->toFailOpenApiValidation([ + '/paths/~1ping/get/parameters/0' => [ + 'The required properties (schema) are missing', + 'The required properties (content) are missing', + ], + ]); +}); + +it('rejects a path parameter where required is overridden to false', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/items/{id}')->operations( + Operation::get() + ->parameters(Parameter::path('id', Schema::string())->required(false)) + ->responses(Response::ok()), + ), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/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 new file mode 100644 index 0000000..4e97f76 --- /dev/null +++ b/tests/Unit/Validation/PathsTest.php @@ -0,0 +1,24 @@ +info(Info::create('x', '1.0.0')) + ->path('noslash', PathItem::create('noslash')->operations( + Operation::get()->responses(Response::ok()), + )); + + expect($openApi)->toFailOpenApiValidationAt( + '/paths', + 'Unevaluated object properties not allowed: noslash', + ); +}); diff --git a/tests/Unit/Validation/RequestBodyTest.php b/tests/Unit/Validation/RequestBodyTest.php new file mode 100644 index 0000000..2f378ee --- /dev/null +++ b/tests/Unit/Validation/RequestBodyTest.php @@ -0,0 +1,43 @@ +info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/items')->operations( + Operation::post() + ->requestBody(RequestBody::create()->json(Schema::object())) + ->responses(Response::created()), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('rejects a requestBody with no content', function (): void { + // An inline requestBody that serialises to {} is dropped by buildArray in Operation, + // so we register it in components.requestBodies where the entry is always preserved. + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->components( + Components::create()->requestBody('Body', RequestBody::create()), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/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 new file mode 100644 index 0000000..f139f3b --- /dev/null +++ b/tests/Unit/Validation/ResponseTest.php @@ -0,0 +1,95 @@ +info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/ping')->operations( + Operation::get()->responses(Response::status(99)), + ), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/paths/~1ping/get/responses', + 'Unevaluated object properties not allowed: 99', + ); +}); + +it('accepts wildcard and default response keys', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/ping')->operations( + Operation::get()->responses( + Response::ok(), + Response::default()->description('Unexpected error'), + Response::status('4XX')->description('Client error'), + ), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('rejects a response object missing its description', function (): void { + // 503 has no default description, so the response serialises to {} which fails required:[description] + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/ping')->operations( + Operation::get()->responses(Response::status(503)), + ), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/paths/~1ping/get/responses/503', + 'The data (array) must match the type: object', + ); +}); + +it('accepts response links and response headers', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/users/{id}')->operations( + Operation::get()->responses( + Response::ok() + ->header('X-Rate-Limit', Header::create()->schema(Schema::integer())) + ->link('GetUser', Link::create()->operationId('getUser')), + ), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('rejects a link with neither operationRef nor operationId', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->paths( + PathItem::create('/ping')->operations( + Operation::get()->responses( + Response::ok()->link('Empty', Link::create()), + ), + ), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/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 new file mode 100644 index 0000000..3a89298 --- /dev/null +++ b/tests/Unit/Validation/SecuritySchemeTest.php @@ -0,0 +1,54 @@ +info(Info::create('x', '1.0.0')) + ->components( + Components::create() + ->securityScheme('apiKey', SecurityScheme::apiKey('X-API-Key', In::Header)) + ->securityScheme('bearer', SecurityScheme::http('bearer')) + ->securityScheme('mutualTls', SecurityScheme::mutualTls()) + ->securityScheme('oauth2', SecurityScheme::oauth2( + OAuthFlows::create()->authorizationCode( + OAuthFlow::create() + ->authorizationUrl('https://example.com/oauth/authorize') + ->tokenUrl('https://example.com/oauth/token') + ->scopes([ + 'read' => 'Read access', + ]), + ), + )) + ->securityScheme( + 'oidc', + SecurityScheme::openIdConnect('https://example.com/.well-known/openid-configuration'), + ), + ); + + expect($openApi)->toPassOpenApiValidation(); +}); + +it('rejects an oauth2 security scheme without flows', function (): void { + $openApi = OpenApi::create() + ->info(Info::create('x', '1.0.0')) + ->components( + Components::create() + ->securityScheme('oauth2', SecurityScheme::oauth2()), + ); + + expect($openApi)->toFailOpenApiValidationAt( + '/components/securitySchemes/oauth2', + 'The required properties (flows) are missing', + ); +}); diff --git a/tests/Unit/Validation/ValidationExceptionTest.php b/tests/Unit/Validation/ValidationExceptionTest.php new file mode 100644 index 0000000..2c1beed --- /dev/null +++ b/tests/Unit/Validation/ValidationExceptionTest.php @@ -0,0 +1,56 @@ +validate(); + $this->fail('Expected ValidationException'); + } catch (ValidationException $validationException) { + expect($validationException->getMessage())->toContain('OpenAPI document failed meta-schema validation:'); + } +}); + +it('validation exception message contains json-encoded errors', function (): void { + $openApi = OpenApi::create(); + + try { + $openApi->validate(); + } catch (ValidationException $validationException) { + // The message should contain both the prefix and JSON-encoded error details + expect($validationException->getMessage())->toStartWith('OpenAPI document failed meta-schema validation: '); + $jsonPart = substr( + $validationException->getMessage(), + strlen('OpenAPI document failed meta-schema validation: '), + ); + expect(json_decode($jsonPart, true))->not->toBeNull(); + } +}); + +it('validation exception carries a structured errors array', function (): void { + $openApi = OpenApi::create(); // missing info — fails validation + + try { + $openApi->validate(); + expect(true)->toBeFalse('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'); + } +}); + +it('ValidationException constructed without errors returns empty array from errors()', function (): void { + $e = new ValidationException('Something went wrong'); + expect($e->errors())->toBe([]); +}); diff --git a/tests/Unit/ValidationTest.php b/tests/Unit/ValidationTest.php deleted file mode 100644 index e497554..0000000 --- a/tests/Unit/ValidationTest.php +++ /dev/null @@ -1,120 +0,0 @@ -info(Info::create('x', '1.0.0')) - ->paths( - PathItem::create('/ping')->operations( - Operation::get()->responses(Response::ok()), - ), - ); - - $openApi->validate(); -})->throwsNoExceptions(); - -it('accepts a minimal valid 3.1.1 document', function (): void { - $openApi = OpenApi::create(OpenApiVersion::V3_1_1) - ->info(Info::create('x', '1.0.0')) - ->paths( - PathItem::create('/ping')->operations( - Operation::get()->responses(Response::ok()), - ), - ); - - $openApi->validate(); -})->throwsNoExceptions(); - -it('accepts a document with components, tags, and schemas', function (): void { - $openApi = OpenApi::create() - ->info(Info::create('Example', '1.0.0')) - ->tags(Tag::create('Users')) - ->components(Components::create()->schema('User', Schema::object()->properties(Schema::string('id')))) - ->paths( - PathItem::create('/users')->operations( - Operation::get()->tags('Users')->responses( - Response::ok()->content(MediaType::json(Schema::string())), - ), - ), - ); - - $openApi->validate(); -})->throwsNoExceptions(); - -it('rejects a document missing info', function (): void { - $openApi = OpenApi::create(); - - expect(fn(): mixed => $openApi->validate())->toThrow(ValidationException::class); -}); - -it('rejects a document with a bad response status key', function (): void { - // 99 is not a valid HTTP status code, nor 'default' - $openApi = OpenApi::create() - ->info(Info::create('x', '1.0.0')) - ->paths( - PathItem::create('/ping')->operations( - Operation::get()->responses(Response::status(99)), - ), - ); - - expect(fn(): mixed => $openApi->validate())->toThrow(ValidationException::class); -}); - -it('validation exception message contains schema validation prefix', function (): void { - $openApi = OpenApi::create(); - - try { - $openApi->validate(); - $this->fail('Expected ValidationException'); - } catch (ValidationException $validationException) { - expect($validationException->getMessage())->toContain('OpenAPI document failed meta-schema validation:'); - } -}); - -it('validation exception message contains json-encoded errors', function (): void { - $openApi = OpenApi::create(); - - try { - $openApi->validate(); - } catch (ValidationException $validationException) { - // The message should contain both the prefix and JSON-encoded error details - expect($validationException->getMessage())->toStartWith('OpenAPI document failed meta-schema validation: '); - $jsonPart = substr( - $validationException->getMessage(), - strlen('OpenAPI document failed meta-schema validation: '), - ); - expect(json_decode($jsonPart, true))->not->toBeNull(); - } -}); - -it('validation exception carries a structured errors array', function (): void { - $openApi = OpenApi::create(); // missing info — fails validation - - try { - $openApi->validate(); - expect(true)->toBeFalse('Expected ValidationException'); - } catch (ValidationException $validationException) { - expect($validationException->errors())->toBeArray(); - expect($validationException->errors())->not->toBeEmpty(); - } -}); - -it('ValidationException constructed without errors returns empty array from errors()', function (): void { - $e = new ValidationException('Something went wrong'); - expect($e->errors())->toBe([]); -}); diff --git a/tests/phpstan/pest-expectations.stub b/tests/phpstan/pest-expectations.stub new file mode 100644 index 0000000..cf8d9aa --- /dev/null +++ b/tests/phpstan/pest-expectations.stub @@ -0,0 +1,18 @@ + toFailOpenApiValidation(array> $expectedErrors) + * @method self toFailOpenApiValidationAt(string $pointer, string $message) + * @method self toPassOpenApiValidation() + */ +class Expectation +{ +}