44
55namespace Studio \OpenApiContractTesting \Laravel ;
66
7+ use const FILTER_NULL_ON_FAILURE ;
8+ use const FILTER_VALIDATE_BOOLEAN ;
9+
710use Illuminate \Testing \TestResponse ;
811use JsonException ;
912use Studio \OpenApiContractTesting \HttpMethod ;
1013use Studio \OpenApiContractTesting \OpenApiCoverageTracker ;
1114use Studio \OpenApiContractTesting \OpenApiResponseValidator ;
1215use Studio \OpenApiContractTesting \OpenApiSpecResolver ;
16+ use Symfony \Component \HttpFoundation \Request ;
1317use Symfony \Component \HttpFoundation \Response ;
1418use WeakMap ;
1519
20+ use function filter_var ;
21+ use function get_debug_type ;
1622use function is_numeric ;
1723use function is_string ;
24+ use function sprintf ;
1825use function str_contains ;
1926use function strtolower ;
27+ use function strtoupper ;
28+ use function var_export ;
2029
2130trait ValidatesOpenApiSchema
2231{
2332 use OpenApiSpecResolver;
2433 private static ?OpenApiResponseValidator $ cachedValidator = null ;
2534 private static ?int $ cachedMaxErrors = null ;
2635
27- /** @var null|WeakMap<TestResponse, true> */
36+ /** @var null|WeakMap<TestResponse, array<string, true> > */
2837 private static ?WeakMap $ validatedResponses = null ;
2938
3039 public static function resetValidatorCache (): void
@@ -35,16 +44,25 @@ public static function resetValidatorCache(): void
3544 }
3645
3746 /**
38- * Overrides Illuminate\Foundation\Testing\TestCase ::createTestResponse so
39- * every HTTP test call runs schema validation when auto_assert is enabled.
47+ * Overrides Laravel's MakesHttpRequests ::createTestResponse hook so every
48+ * HTTP test call runs schema validation when auto_assert is enabled.
4049 * When the library is used outside Laravel, this method is never called.
4150 *
51+ * Method and path are resolved from the Request passed in by Laravel
52+ * rather than from app('request'), so auto-assert stays independent of
53+ * container state and sees the exact values the framework dispatched.
54+ *
4255 * @param Response $response
56+ * @param null|Request $request
4357 */
4458 protected function createTestResponse ($ response , $ request = null ): TestResponse
4559 {
4660 $ testResponse = parent ::createTestResponse ($ response , $ request );
47- $ this ->maybeAutoAssertOpenApiSchema ($ testResponse );
61+
62+ $ method = $ request !== null ? HttpMethod::tryFrom (strtoupper ($ request ->getMethod ())) : null ;
63+ $ path = $ request ?->getPathInfo();
64+
65+ $ this ->maybeAutoAssertOpenApiSchema ($ testResponse , $ method , $ path );
4866
4967 return $ testResponse ;
5068 }
@@ -54,11 +72,7 @@ protected function maybeAutoAssertOpenApiSchema(
5472 ?HttpMethod $ method = null ,
5573 ?string $ path = null ,
5674 ): void {
57- if (config ('openapi-contract-testing.auto_assert ' ) !== true ) {
58- return ;
59- }
60-
61- if (self ::isAlreadyValidated ($ response )) {
75+ if (!$ this ->isAutoAssertEnabled ()) {
6276 return ;
6377 }
6478
@@ -86,10 +100,8 @@ protected function assertResponseMatchesOpenApiSchema(
86100 ?HttpMethod $ method = null ,
87101 ?string $ path = null ,
88102 ): void {
89- if (self ::isAlreadyValidated ($ response )) {
90- return ;
91- }
92- self ::markValidated ($ response );
103+ $ resolvedMethod = $ method !== null ? $ method ->value : app ('request ' )->getMethod ();
104+ $ resolvedPath = $ path ?? app ('request ' )->getPathInfo ();
93105
94106 $ specName = $ this ->resolveOpenApiSpec ();
95107 if ($ specName === '' ) {
@@ -101,8 +113,16 @@ protected function assertResponseMatchesOpenApiSchema(
101113 );
102114 }
103115
104- $ resolvedMethod = $ method !== null ? $ method ->value : app ('request ' )->getMethod ();
105- $ resolvedPath = $ path ?? app ('request ' )->getPathInfo ();
116+ // Idempotency key includes the spec so that validating the same
117+ // response against a different spec (or a different method/path on
118+ // the same spec) still runs — auto-assert's no-op only applies to
119+ // exact repeats.
120+ $ signature = $ specName . ': ' . $ resolvedMethod . ' ' . $ resolvedPath ;
121+
122+ if (self ::isAlreadyValidated ($ response , $ signature )) {
123+ return ;
124+ }
125+ self ::markValidated ($ response , $ signature );
106126
107127 $ content = $ response ->getContent ();
108128 if ($ content === false ) {
@@ -124,6 +144,8 @@ protected function assertResponseMatchesOpenApiSchema(
124144 // Record coverage for any matched endpoint, including those where body
125145 // validation was skipped (e.g. non-JSON content types). "Covered" means
126146 // the endpoint was exercised in a test, not that its body was validated.
147+ // Note: under auto_assert, this records coverage for every Laravel HTTP
148+ // call — including responses with no explicit contract-test intent.
127149 if ($ result ->matchedPath () !== null ) {
128150 OpenApiCoverageTracker::record (
129151 $ specName ,
@@ -139,16 +161,18 @@ protected function assertResponseMatchesOpenApiSchema(
139161 );
140162 }
141163
142- private static function isAlreadyValidated (TestResponse $ response ): bool
164+ private static function isAlreadyValidated (TestResponse $ response, string $ signature ): bool
143165 {
144166 return self ::$ validatedResponses !== null &&
145- isset (self ::$ validatedResponses [$ response ]);
167+ isset (self ::$ validatedResponses [$ response ][ $ signature ] );
146168 }
147169
148- private static function markValidated (TestResponse $ response ): void
170+ private static function markValidated (TestResponse $ response, string $ signature ): void
149171 {
150172 self ::$ validatedResponses ??= new WeakMap ();
151- self ::$ validatedResponses [$ response ] = true ;
173+ $ signatures = self ::$ validatedResponses [$ response ] ?? [];
174+ $ signatures [$ signature ] = true ;
175+ self ::$ validatedResponses [$ response ] = $ signatures ;
152176 }
153177
154178 private static function getOrCreateValidator (): OpenApiResponseValidator
@@ -164,6 +188,30 @@ private static function getOrCreateValidator(): OpenApiResponseValidator
164188 return self ::$ cachedValidator ;
165189 }
166190
191+ private function isAutoAssertEnabled (): bool
192+ {
193+ $ raw = config ('openapi-contract-testing.auto_assert ' , false );
194+
195+ if ($ raw === true ) {
196+ return true ;
197+ }
198+ if ($ raw === false || $ raw === null ) {
199+ return false ;
200+ }
201+
202+ $ parsed = filter_var ($ raw , FILTER_VALIDATE_BOOLEAN , FILTER_NULL_ON_FAILURE );
203+ if ($ parsed === null ) {
204+ $ this ->fail (sprintf (
205+ 'openapi-contract-testing.auto_assert must be a boolean (or a boolean-compatible value '
206+ . 'like "true"/"false"/"1"/"0"), got %s: %s. ' ,
207+ get_debug_type ($ raw ),
208+ var_export ($ raw , true ),
209+ ));
210+ }
211+
212+ return $ parsed ;
213+ }
214+
167215 /** @return null|array<string, mixed> */
168216 private function extractJsonBody (TestResponse $ response , string $ content , string $ contentType ): ?array
169217 {
0 commit comments