@@ -94,7 +94,6 @@ public function auto_assert_false_skips_validation_for_invalid_response(): void
9494 $ body = (string ) json_encode (['wrong_key ' => 'value ' ], JSON_THROW_ON_ERROR );
9595 $ response = $ this ->makeTestResponse ($ body , 200 );
9696
97- // No exception expected — validation is skipped.
9897 $ this ->maybeAutoAssertOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
9998
10099 $ covered = OpenApiCoverageTracker::getCovered ();
@@ -104,7 +103,6 @@ public function auto_assert_false_skips_validation_for_invalid_response(): void
104103 #[Test]
105104 public function auto_assert_not_set_skips_validation_for_invalid_response (): void
106105 {
107- // No auto_assert config key present — default should be false.
108106 $ body = (string ) json_encode (['wrong_key ' => 'value ' ], JSON_THROW_ON_ERROR );
109107 $ response = $ this ->makeTestResponse ($ body , 200 );
110108
@@ -115,7 +113,45 @@ public function auto_assert_not_set_skips_validation_for_invalid_response(): voi
115113 }
116114
117115 #[Test]
118- public function double_manual_assert_is_idempotent (): void
116+ public function auto_assert_with_non_bool_value_fails_loudly (): void
117+ {
118+ // A user who mis-configures auto_assert (e.g. via env without cast)
119+ // should see a loud failure, not a silent skip.
120+ $ GLOBALS ['__openapi_testing_config ' ]['openapi-contract-testing.auto_assert ' ] = 'yolo ' ;
121+
122+ $ body = (string ) json_encode (
123+ ['data ' => [['id ' => 1 , 'name ' => 'Fido ' , 'tag ' => null ]]],
124+ JSON_THROW_ON_ERROR ,
125+ );
126+ $ response = $ this ->makeTestResponse ($ body , 200 );
127+
128+ $ this ->expectException (AssertionFailedError::class);
129+ $ this ->expectExceptionMessage ('auto_assert must be a boolean ' );
130+
131+ $ this ->maybeAutoAssertOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
132+ }
133+
134+ #[Test]
135+ public function auto_assert_with_truthy_string_validates (): void
136+ {
137+ // env('X') returns strings; "true" must be treated as truthy so that
138+ // `'auto_assert' => env('AUTO_ASSERT')` (the idiomatic Laravel
139+ // pattern) works without an explicit cast.
140+ $ GLOBALS ['__openapi_testing_config ' ]['openapi-contract-testing.auto_assert ' ] = 'true ' ;
141+
142+ $ body = (string ) json_encode (
143+ ['data ' => [['id ' => 1 , 'name ' => 'Fido ' , 'tag ' => null ]]],
144+ JSON_THROW_ON_ERROR ,
145+ );
146+ $ response = $ this ->makeTestResponse ($ body , 200 );
147+
148+ $ this ->maybeAutoAssertOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
149+
150+ $ this ->assertArrayHasKey ('petstore-3.0 ' , OpenApiCoverageTracker::getCovered ());
151+ }
152+
153+ #[Test]
154+ public function double_manual_assert_with_same_signature_is_idempotent (): void
119155 {
120156 $ body = (string ) json_encode (
121157 ['data ' => [['id ' => 1 , 'name ' => 'Fido ' , 'tag ' => null ]]],
@@ -128,15 +164,11 @@ public function double_manual_assert_is_idempotent(): void
128164
129165 $ covered = OpenApiCoverageTracker::getCovered ();
130166 $ this ->assertArrayHasKey ('petstore-3.0 ' , $ covered );
131- $ this ->assertCount (
132- 1 ,
133- $ covered ['petstore-3.0 ' ],
134- 'Coverage entries should not be duplicated when the same response is validated twice. ' ,
135- );
167+ $ this ->assertCount (1 , $ covered ['petstore-3.0 ' ]);
136168 }
137169
138170 #[Test]
139- public function manual_assert_then_auto_assert_is_idempotent (): void
171+ public function manual_then_auto_assert_with_same_signature_is_idempotent (): void
140172 {
141173 $ GLOBALS ['__openapi_testing_config ' ]['openapi-contract-testing.auto_assert ' ] = true ;
142174
@@ -147,27 +179,19 @@ public function manual_assert_then_auto_assert_is_idempotent(): void
147179 $ response = $ this ->makeTestResponse ($ body , 200 );
148180
149181 $ this ->assertResponseMatchesOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
182+ $ countBefore = count (OpenApiCoverageTracker::getCovered ()['petstore-3.0 ' ] ?? []);
150183
151- $ coveredBefore = OpenApiCoverageTracker::getCovered ();
152- $ countBefore = count ($ coveredBefore ['petstore-3.0 ' ] ?? []);
153-
154- // A subsequent auto-assert on the same response must not re-validate or
155- // re-record coverage.
156184 $ this ->maybeAutoAssertOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
157185
158- $ coveredAfter = OpenApiCoverageTracker::getCovered ();
159- $ this ->assertCount ($ countBefore , $ coveredAfter ['petstore-3.0 ' ] ?? []);
186+ $ this ->assertCount ($ countBefore , OpenApiCoverageTracker::getCovered ()['petstore-3.0 ' ] ?? []);
160187 }
161188
162189 #[Test]
163- public function auto_assert_then_manual_assert_does_not_raise_for_invalid_response (): void
190+ public function auto_then_manual_assert_with_same_signature_does_not_duplicate_coverage (): void
164191 {
165- // When auto-assert has already failed (and been caught), a manual
166- // assert on the same response instance should no-op so the same error
167- // is not reported twice. We simulate this by recording the response as
168- // validated via a successful auto-assert first, then calling the
169- // manual API — which would normally raise on a subsequent mismatch —
170- // expecting no exception because the response is already marked.
192+ // After a successful auto-assert, a manual call with the matching
193+ // (method, path) signature must no-op — no second validator run,
194+ // no second coverage entry.
171195 $ GLOBALS ['__openapi_testing_config ' ]['openapi-contract-testing.auto_assert ' ] = true ;
172196
173197 $ body = (string ) json_encode (
@@ -177,11 +201,27 @@ public function auto_assert_then_manual_assert_does_not_raise_for_invalid_respon
177201 $ response = $ this ->makeTestResponse ($ body , 200 );
178202
179203 $ this ->maybeAutoAssertOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
180- // Calling manual assert afterwards must not throw or add another
181- // coverage entry.
182204 $ this ->assertResponseMatchesOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
183205
184- $ covered = OpenApiCoverageTracker::getCovered ();
185- $ this ->assertCount (1 , $ covered ['petstore-3.0 ' ] ?? []);
206+ $ this ->assertCount (1 , OpenApiCoverageTracker::getCovered ()['petstore-3.0 ' ] ?? []);
207+ }
208+
209+ #[Test]
210+ public function manual_assert_with_different_method_path_re_validates_same_response (): void
211+ {
212+ // Idempotency is keyed on (spec, method, path) — a second call with
213+ // different (method, path) must re-validate, not silently no-op.
214+ // We prove this by making the second call one that SHOULD fail:
215+ // if idempotency wrongly skipped it, no exception would be raised.
216+ //
217+ // The empty 204 body validates against DELETE /v1/pets/{petId} but
218+ // does NOT satisfy GET /v1/pets (which expects a JSON body). Before
219+ // the tuple-keyed fix, this second call was silently skipped.
220+ $ response = $ this ->makeTestResponse ('' , 204 );
221+
222+ $ this ->assertResponseMatchesOpenApiSchema ($ response , HttpMethod::DELETE , '/v1/pets/123 ' );
223+
224+ $ this ->expectException (AssertionFailedError::class);
225+ $ this ->assertResponseMatchesOpenApiSchema ($ response , HttpMethod::GET , '/v1/pets ' );
186226 }
187227}
0 commit comments