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 '