From 37b6de533a9b86bd7640c5a950bed254e088638b Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 18 May 2026 15:04:14 +0900 Subject: [PATCH 1/3] test(validation): pin the DecodedBody envelope unwrap on the strict-required path (#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #247 added an unwrap step so the strict-required walker observes the decoded body value rather than the present-literal-null marker. #248 replaced that marker with the DecodedBody envelope, so the unwrap is now `$body->value` in OpenApiResponseValidator::maybeRecordStrictRequired(). That path had no direct regression test — the pr-test review flagged it. Add two regression tests to StrictRequiredValidatorIntegrationTest: - present_literal_null_body_records_no_strict_required_pointer: a literal JSON null body (DecodedBody::present(null)) against a nullable object schema reaches the Success path and records no pointer — documenting that the walker observes a real null, not the envelope. - decoded_body_envelope_is_unwrapped_before_strict_required_walk: a present object body passed as a DecodedBody envelope must be unwrapped before walking. Fails the moment `$body->value` becomes `$body`, since collectPointers() would receive a DecodedBody and return an empty map. Add a `/nullable-object` endpoint to the under-described fixture so the literal-null body validates successfully. --- ...StrictRequiredValidatorIntegrationTest.php | 58 +++++++++++++++++++ tests/fixtures/specs/under-described.json | 21 +++++++ 2 files changed, 79 insertions(+) diff --git a/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php b/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php index f8bce49..72d7b08 100644 --- a/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php +++ b/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use stdClass; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; use Studio\OpenApiContractTesting\Validation\Strict\StrictRequiredAsserter; @@ -199,6 +200,63 @@ public function null_body_is_not_recorded(): void $this->assertSame([], StrictRequiredTracker::getObservations('under-described')); } + #[Test] + public function present_literal_null_body_records_no_strict_required_pointer(): void + { + // Issues #246 / #248 / #249: a literal JSON `null` body reaches the + // validator as DecodedBody::present(null) — distinct from an absent + // body. /nullable-object declares a `nullable: true` object schema, + // so the null body passes conformance and the validator hits the + // Success path where maybeRecordStrictRequired() runs. The strict- + // required walker must observe the unwrapped real `null` + // (OpenApiResponseValidator passes `$body->value`, not the envelope), + // which collectPointers() maps to an empty pointer map — so nothing + // is recorded. A regression that fed the walker a non-null marker / + // envelope would still record `[]` here, but the present-object + // sibling test below pins the unwrap with teeth. + $result = $this->validator->validate( + 'under-described', + 'GET', + '/nullable-object', + 200, + DecodedBody::present(null), + 'application/json', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame([], StrictRequiredTracker::getObservations('under-described')); + } + + #[Test] + public function decoded_body_envelope_is_unwrapped_before_strict_required_walk(): void + { + // Issue #249: the framework adapters pass a DecodedBody envelope to + // validate(); maybeRecordStrictRequired() must hand the strict- + // required walker the *unwrapped* decoded value (`$body->value`), + // not the DecodedBody object itself. If the unwrap were dropped, the + // walker would receive a DecodedBody instance — neither stdClass nor + // array — and collectPointers() would return `[]`, silently recording + // no observation. Asserting the `/` pointer carries the inner array's + // keys pins the unwrap: this test fails the moment `$body->value` + // becomes `$body`. + $result = $this->validator->validate( + 'under-described', + 'PUT', + '/signed-url', + 200, + DecodedBody::present(['expires' => 3600, 'signed_url' => 's3://...', 'url' => 'https://...']), + 'application/json', + ); + + $this->assertTrue($result->isValid()); + + $observations = StrictRequiredTracker::getObservations('under-described'); + $this->assertSame( + ['hits' => 1, 'pointers' => ['/' => ['expires', 'signed_url', 'url']]], + $observations['PUT /signed-url']['200:application/json'], + ); + } + #[Test] public function list_body_records_star_pointer_observations(): void { diff --git a/tests/fixtures/specs/under-described.json b/tests/fixtures/specs/under-described.json index 8c6129c..b5a638b 100644 --- a/tests/fixtures/specs/under-described.json +++ b/tests/fixtures/specs/under-described.json @@ -317,6 +317,27 @@ } } } + }, + "/nullable-object": { + "get": { + "responses": { + "200": { + "description": "ok; nullable object — a literal JSON null body validates", + "content": { + "application/json": { + "schema": { + "type": "object", + "nullable": true, + "required": ["id"], + "properties": { + "id": { "type": "string" } + } + } + } + } + } + } + } } } } From 7f2c1c8ebff4f5d9ecd3035b5480a1082c98ac10 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 18 May 2026 15:09:11 +0900 Subject: [PATCH 2/3] test(validation): correct review-flagged comment claims and trim fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address pr-review-toolkit feedback on the #249 regression tests: - Drop the inaccurate #246 citation: the DecodedBody::present(null) representation is #248's work, not #246 (which only added the superseded marker enum). - Reframe the literal-null test docstring as a characterization test for the Success path — it does not pin the unwrap (a leaked envelope also yields an empty pointer map), which the sibling test guards. - Clarify that a dropped unwrap skips the observation entirely rather than recording an empty one, and note the teeth test's expected keys come from the input body, not the spec schema. - Remove the inert `required: ["id"]` from the /nullable-object fixture; the literal-null body never exercises a required-key comparison. --- ...StrictRequiredValidatorIntegrationTest.php | 37 +++++++++++-------- tests/fixtures/specs/under-described.json | 1 - 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php b/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php index 72d7b08..bcb1e5a 100644 --- a/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php +++ b/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php @@ -203,17 +203,21 @@ public function null_body_is_not_recorded(): void #[Test] public function present_literal_null_body_records_no_strict_required_pointer(): void { - // Issues #246 / #248 / #249: a literal JSON `null` body reaches the - // validator as DecodedBody::present(null) — distinct from an absent - // body. /nullable-object declares a `nullable: true` object schema, - // so the null body passes conformance and the validator hits the - // Success path where maybeRecordStrictRequired() runs. The strict- - // required walker must observe the unwrapped real `null` - // (OpenApiResponseValidator passes `$body->value`, not the envelope), - // which collectPointers() maps to an empty pointer map — so nothing - // is recorded. A regression that fed the walker a non-null marker / - // envelope would still record `[]` here, but the present-object - // sibling test below pins the unwrap with teeth. + // Issues #248 / #249: a literal JSON `null` body reaches the + // validator as DecodedBody::present(null) — the envelope introduced + // in #248, distinct from an absent body. /nullable-object declares a + // `nullable: true` object schema, so the null body passes conformance + // and the validator reaches the Success path where + // maybeRecordStrictRequired() runs. This test pins that path: a + // present literal-null body validates (isValid true) and records no + // strict-required observation, because collectPointers() maps a real + // `null` to an empty pointer map. + // + // This is a characterization test for the literal-null Success path, + // not the unwrap guard: feeding the walker the DecodedBody envelope + // instead of `$body->value` would also yield `[]` here (a DecodedBody + // is neither array nor stdClass). The unwrap itself is pinned with + // teeth by the sibling test below. $result = $this->validator->validate( 'under-described', 'GET', @@ -235,10 +239,13 @@ public function decoded_body_envelope_is_unwrapped_before_strict_required_walk() // required walker the *unwrapped* decoded value (`$body->value`), // not the DecodedBody object itself. If the unwrap were dropped, the // walker would receive a DecodedBody instance — neither stdClass nor - // array — and collectPointers() would return `[]`, silently recording - // no observation. Asserting the `/` pointer carries the inner array's - // keys pins the unwrap: this test fails the moment `$body->value` - // becomes `$body`. + // array — and collectPointers() would return `[]`, silently skipping + // the observation entirely (recordOn() is never reached). Asserting + // the `/` pointer carries the body's keys pins the unwrap: this test + // fails the moment `$body->value` becomes `$body`. + // + // The expected pointer keys come from this test's input body, not + // from the /signed-url spec schema (which declares no `required`). $result = $this->validator->validate( 'under-described', 'PUT', diff --git a/tests/fixtures/specs/under-described.json b/tests/fixtures/specs/under-described.json index b5a638b..6128da7 100644 --- a/tests/fixtures/specs/under-described.json +++ b/tests/fixtures/specs/under-described.json @@ -328,7 +328,6 @@ "schema": { "type": "object", "nullable": true, - "required": ["id"], "properties": { "id": { "type": "string" } } From e3bdea7362086329750c8db59364a39e3cc6f010 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 18 May 2026 15:12:30 +0900 Subject: [PATCH 3/3] test(validation): cover the absent envelope and the OAS 3.1 nullable path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the two coverage gaps the pr-test review flagged on the #249 regression tests: - absent_decoded_body_against_json_schema_fails_and_records_nothing: the framework adapters build the envelope directly, so the production path for a missing body is an explicit DecodedBody::absent() — not a bare null through fromLegacy(). Passing absent() against a JSON-schema endpoint must fail conformance and record nothing, mirroring the present-literal-null case (present(null) validates, absent() does not). - present_literal_null_body_on_oas31_nullable_schema_records_no_pointer: the literal-null Success path is version-specific. The existing test pins OAS 3.0 `nullable: true`; this one pins OAS 3.1 `type: ["object", "null"]` via a new under-described-3.1 fixture, so a converter regression on 3.1 type arrays surfaces here. --- ...StrictRequiredValidatorIntegrationTest.php | 49 +++++++++++++++++++ tests/fixtures/specs/under-described-3.1.json | 25 ++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/fixtures/specs/under-described-3.1.json diff --git a/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php b/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php index bcb1e5a..61b648b 100644 --- a/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php +++ b/tests/Unit/Validation/Strict/StrictRequiredValidatorIntegrationTest.php @@ -231,6 +231,55 @@ public function present_literal_null_body_records_no_strict_required_pointer(): $this->assertSame([], StrictRequiredTracker::getObservations('under-described')); } + #[Test] + public function present_literal_null_body_on_oas31_nullable_schema_records_no_pointer(): void + { + // The literal-null Success path is version-specific: OAS 3.0 spells a + // nullable object `nullable: true`, OAS 3.1 spells it + // `type: ["object", "null"]`, and OpenApiSchemaConverter routes the + // two through different branches. The sibling test above pins the 3.0 + // form; this one pins the 3.1 form against under-described-3.1, so a + // converter regression on 3.1 type arrays — which would stop a null + // body from passing conformance — surfaces here. + $result = $this->validator->validate( + 'under-described-3.1', + 'GET', + '/nullable-object', + 200, + DecodedBody::present(null), + 'application/json', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame([], StrictRequiredTracker::getObservations('under-described-3.1')); + } + + #[Test] + public function absent_decoded_body_against_json_schema_fails_and_records_nothing(): void + { + // Issues #248 / #249: the framework adapters build the envelope + // directly, so the production path for a missing body is an explicit + // DecodedBody::absent() reaching validate() — not a bare null routed + // through fromLegacy() (the path null_body_is_not_recorded covers). + // Passing absent() against /nullable-object, which declares a JSON + // schema, must fail conformance ("empty body") and therefore record + // no strict-required observation — the mirror of the present-literal- + // null case above: present(null) validates, absent() does not. This + // pins the absent/present distinction the DecodedBody envelope exists + // to carry, on the direct adapter path. + $result = $this->validator->validate( + 'under-described', + 'GET', + '/nullable-object', + 200, + DecodedBody::absent(), + 'application/json', + ); + + $this->assertFalse($result->isValid()); + $this->assertSame([], StrictRequiredTracker::getObservations('under-described')); + } + #[Test] public function decoded_body_envelope_is_unwrapped_before_strict_required_walk(): void { diff --git a/tests/fixtures/specs/under-described-3.1.json b/tests/fixtures/specs/under-described-3.1.json new file mode 100644 index 0000000..45e1b4c --- /dev/null +++ b/tests/fixtures/specs/under-described-3.1.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Strict required fixture (OAS 3.1)", "version": "1.0.0" }, + "paths": { + "/nullable-object": { + "get": { + "responses": { + "200": { + "description": "ok; OAS 3.1 nullable object (type: [object, null]) — a literal JSON null body validates", + "content": { + "application/json": { + "schema": { + "type": ["object", "null"], + "properties": { + "id": { "type": "string" } + } + } + } + } + } + } + } + } + } +}