Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -199,6 +200,119 @@ 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 #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',
'/nullable-object',
200,
DecodedBody::present(null),
'application/json',
);

$this->assertTrue($result->isValid());
$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
{
// 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 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',
'/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
{
Expand Down
25 changes: 25 additions & 0 deletions tests/fixtures/specs/under-described-3.1.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
}
}
}
}
}
}
}
20 changes: 20 additions & 0 deletions tests/fixtures/specs/under-described.json
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,26 @@
}
}
}
},
"/nullable-object": {
"get": {
"responses": {
"200": {
"description": "ok; nullable object — a literal JSON null body validates",
"content": {
"application/json": {
"schema": {
"type": "object",
"nullable": true,
"properties": {
"id": { "type": "string" }
}
}
}
}
}
}
}
}
}
}
Loading