diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index da4053e..09c9c13 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -61,6 +61,9 @@ protected function assertResponseMatchesOpenApiSchema( $this->extractJsonBody($response, $content, $contentType), ); + // Record coverage for any matched endpoint, including those where body + // validation was skipped (e.g. non-JSON content types). "Covered" means + // the endpoint was exercised in a test, not that its body was validated. if ($result->matchedPath() !== null) { OpenApiCoverageTracker::record( $specName, @@ -69,6 +72,10 @@ protected function assertResponseMatchesOpenApiSchema( ); } + // This guard catches the case where the spec defines a JSON content type + // but the actual response has a non-JSON Content-Type header. The validator + // itself skips validation for specs that define *only* non-JSON content + // types (returning success), so this guard only fires for the mismatch case. if (!$result->isValid() && $hasNonJsonContentType) { $this->fail( "OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n" diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index cadc64f..5f569b3 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -10,7 +10,6 @@ use Opis\JsonSchema\Validator; use function array_keys; -use function implode; use function json_decode; use function json_encode; use function str_ends_with; @@ -69,12 +68,11 @@ public function validate( $content = $responseSpec['content']; $jsonContentType = $this->findJsonContentType($content); + // If no JSON-compatible content type is defined, skip body validation. + // This validator only handles JSON schemas; non-JSON types (e.g. text/html, + // application/xml) are outside its scope. if ($jsonContentType === null) { - $definedTypes = array_keys($content); - - return OpenApiValidationResult::failure([ - "No JSON-compatible content type found for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: " . implode(', ', $definedTypes), - ]); + return OpenApiValidationResult::success($matchedPath); } if (!isset($content[$jsonContentType]['schema'])) { diff --git a/tests/Integration/ResponseValidationTest.php b/tests/Integration/ResponseValidationTest.php index 11118b7..2207ba2 100644 --- a/tests/Integration/ResponseValidationTest.php +++ b/tests/Integration/ResponseValidationTest.php @@ -64,11 +64,12 @@ public function full_pipeline_v30_validate_and_track_coverage(): void // Check coverage $coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0'); - $this->assertSame(5, $coverage['total']); + $this->assertSame(6, $coverage['total']); $this->assertSame(2, $coverage['coveredCount']); $this->assertContains('GET /v1/pets', $coverage['covered']); $this->assertContains('POST /v1/pets', $coverage['covered']); $this->assertContains('GET /v1/health', $coverage['uncovered']); + $this->assertContains('GET /v1/logout', $coverage['uncovered']); $this->assertContains('DELETE /v1/pets/{petId}', $coverage['uncovered']); $this->assertContains('GET /v1/pets/{petId}', $coverage['uncovered']); } @@ -94,6 +95,27 @@ public function full_pipeline_v31_validate_and_track_coverage(): void $this->assertSame(1, $coverage['coveredCount']); } + #[Test] + public function non_json_endpoint_skips_validation_and_records_coverage(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/logout', + 200, + 'Logged out', + ); + $this->assertTrue($result->isValid()); + + if ($result->matchedPath() !== null) { + OpenApiCoverageTracker::record('petstore-3.0', 'GET', $result->matchedPath()); + } + + $coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0'); + $this->assertSame(1, $coverage['coveredCount']); + $this->assertContains('GET /v1/logout', $coverage['covered']); + } + #[Test] public function invalid_response_produces_descriptive_errors(): void { diff --git a/tests/Unit/OpenApiCoverageTrackerTest.php b/tests/Unit/OpenApiCoverageTrackerTest.php index f2e5fce..5bd55c8 100644 --- a/tests/Unit/OpenApiCoverageTrackerTest.php +++ b/tests/Unit/OpenApiCoverageTrackerTest.php @@ -67,10 +67,10 @@ public function compute_coverage_returns_correct_stats(): void $result = OpenApiCoverageTracker::computeCoverage('petstore-3.0'); // See tests/fixtures/specs/petstore-3.0.json for the full endpoint list - $this->assertSame(5, $result['total']); + $this->assertSame(6, $result['total']); $this->assertSame(2, $result['coveredCount']); $this->assertCount(2, $result['covered']); - $this->assertCount(3, $result['uncovered']); + $this->assertCount(4, $result['uncovered']); } #[Test] @@ -78,10 +78,10 @@ public function compute_coverage_with_no_coverage(): void { $result = OpenApiCoverageTracker::computeCoverage('petstore-3.0'); - $this->assertSame(5, $result['total']); + $this->assertSame(6, $result['total']); $this->assertSame(0, $result['coveredCount']); $this->assertCount(0, $result['covered']); - $this->assertCount(5, $result['uncovered']); + $this->assertCount(6, $result['uncovered']); } #[Test] diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index bb7b275..e025956 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -211,7 +211,7 @@ public function v30_problem_json_empty_body_fails(): void } #[Test] - public function v30_content_without_json_compatible_type_fails(): void + public function v30_non_json_content_type_skips_validation(): void { $result = $this->validator->validate( 'petstore-3.0', @@ -221,9 +221,8 @@ public function v30_content_without_json_compatible_type_fails(): void 'Unsupported', ); - $this->assertFalse($result->isValid()); - $this->assertStringContainsString('No JSON-compatible content type found', $result->errors()[0]); - $this->assertStringContainsString('application/xml', $result->errors()[0]); + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); } #[Test] @@ -260,6 +259,51 @@ public function v30_json_content_type_without_schema_skips_validation(): void $this->assertSame('/v1/pets', $result->matchedPath()); } + #[Test] + public function v30_text_html_only_content_type_skips_validation(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/logout', + 200, + 'Logged out', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/logout', $result->matchedPath()); + } + + #[Test] + public function v30_mixed_json_and_non_json_content_types_validates_json_schema(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + ['error' => 'Pet already exists'], + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_mixed_content_types_with_invalid_json_body_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + ['wrong_key' => 'value'], + ); + + $this->assertFalse($result->isValid()); + $this->assertNotEmpty($result->errors()); + } + // ======================================== // OAS 3.1 tests // ======================================== @@ -324,7 +368,7 @@ public function v31_problem_json_valid_response_passes(): void } #[Test] - public function v31_content_without_json_compatible_type_fails(): void + public function v31_non_json_content_type_skips_validation(): void { $result = $this->validator->validate( 'petstore-3.1', @@ -334,9 +378,8 @@ public function v31_content_without_json_compatible_type_fails(): void 'Unsupported', ); - $this->assertFalse($result->isValid()); - $this->assertStringContainsString('No JSON-compatible content type found', $result->errors()[0]); - $this->assertStringContainsString('application/xml', $result->errors()[0]); + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); } #[Test] diff --git a/tests/fixtures/specs/petstore-3.0.json b/tests/fixtures/specs/petstore-3.0.json index 970c7c4..7f06637 100644 --- a/tests/fixtures/specs/petstore-3.0.json +++ b/tests/fixtures/specs/petstore-3.0.json @@ -130,6 +130,27 @@ } } }, + "409": { + "description": "Conflict", + "content": { + "text/html": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "415": { "description": "Unsupported media type", "content": { @@ -143,6 +164,24 @@ } } }, + "/v1/logout": { + "get": { + "summary": "Logout page", + "operationId": "logout", + "responses": { + "200": { + "description": "Logout confirmation page", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/v1/health": { "get": { "summary": "Health check",