Skip to content

Commit 714b9f2

Browse files
committed
test(laravel): cover POST, attribute resolution, and tuple idempotency
Addresses PR #54 review feedback (pr-test-analyzer, comment-analyzer): - Integration: POST auto-assert, #[OpenApiSpec] attribute interaction, invalid-config fail-loud, truthy-string acceptance. - Unit: non-bool-value fails loudly, truthy-string validates, and a different (method, path) signature re-validates the same response instead of silently no-oping (the tuple-keyed idempotency guarantee). - Renamed auto_assert_then_manual_assert_does_not_raise_for_invalid_response to accurately describe what it tests (same-signature coverage de-duplication, not a caught-failure scenario). - Removed redundant WHAT-style comments duplicated by test names.
1 parent 88ffb02 commit 714b9f2

2 files changed

Lines changed: 145 additions & 47 deletions

File tree

tests/Integration/Laravel/AutoAssertIntegrationTest.php

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@
1111
use Studio\OpenApiContractTesting\Laravel\OpenApiContractTestingServiceProvider;
1212
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema;
1313
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
14+
use Studio\OpenApiContractTesting\OpenApiSpec;
1415
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
1516

1617
use function dirname;
1718

1819
/**
19-
* Full Laravel integration test proving that applying the
20-
* ValidatesOpenApiSchema trait with auto_assert=true is sufficient for
21-
* $this->get() / post() / etc. to trigger OpenAPI validation — no explicit
22-
* assertResponseMatchesOpenApiSchema() call required.
20+
* Exercises the full `$this->get()` / `$this->post()` → Laravel kernel →
21+
* `MakesHttpRequests::createTestResponse` → trait override pipeline under a
22+
* real Testbench app. Complements the unit tests (which call
23+
* `maybeAutoAssertOpenApiSchema` directly) by proving the framework-boundary
24+
* integration — the trait-provided `createTestResponse` actually wins over
25+
* the one Laravel merges in from `MakesHttpRequests`.
2326
*/
2427
class AutoAssertIntegrationTest extends TestCase
2528
{
@@ -43,14 +46,11 @@ protected function tearDown(): void
4346
}
4447

4548
#[Test]
46-
public function auto_assert_true_validates_http_response_without_explicit_call(): void
49+
public function auto_assert_true_validates_get_response(): void
4750
{
4851
config()->set('openapi-contract-testing.auto_assert', true);
4952

50-
// No explicit assertResponseMatchesOpenApiSchema call — trait alone
51-
// must trigger validation when auto_assert=true.
5253
$response = $this->get('/v1/pets');
53-
5454
$response->assertOk();
5555

5656
$covered = OpenApiCoverageTracker::getCovered();
@@ -59,57 +59,110 @@ public function auto_assert_true_validates_http_response_without_explicit_call()
5959
}
6060

6161
#[Test]
62-
public function auto_assert_true_raises_assertion_error_on_schema_mismatch(): void
62+
public function auto_assert_true_validates_post_response(): void
63+
{
64+
// Pins POST specifically: the hook is verb-agnostic in theory, but a
65+
// regression that guards createTestResponse behind `method === 'GET'`
66+
// would otherwise go undetected.
67+
config()->set('openapi-contract-testing.auto_assert', true);
68+
69+
$response = $this->postJson('/v1/pets', ['name' => 'Buddy']);
70+
$response->assertCreated();
71+
72+
$covered = OpenApiCoverageTracker::getCovered();
73+
$this->assertArrayHasKey('POST /v1/pets', $covered['petstore-3.0'] ?? []);
74+
}
75+
76+
#[Test]
77+
public function auto_assert_true_raises_on_schema_mismatch(): void
6378
{
6479
config()->set('openapi-contract-testing.auto_assert', true);
6580

6681
$this->expectException(AssertionFailedError::class);
6782
$this->expectExceptionMessage('OpenAPI schema validation failed');
6883

69-
// Auto-assert must surface the mismatch without an explicit assert.
7084
$this->get('/v1/pets?bad=1');
7185
}
7286

7387
#[Test]
74-
public function auto_assert_false_does_not_validate_automatically(): void
88+
public function auto_assert_false_does_not_validate(): void
7589
{
7690
config()->set('openapi-contract-testing.auto_assert', false);
7791

78-
// Invalid body but auto_assert=false — no exception, no coverage.
7992
$response = $this->get('/v1/pets?bad=1');
80-
8193
$response->assertOk();
8294

83-
$covered = OpenApiCoverageTracker::getCovered();
84-
$this->assertArrayNotHasKey('petstore-3.0', $covered);
95+
$this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered());
8596
}
8697

8798
#[Test]
8899
public function auto_assert_not_set_defaults_to_skip(): void
89100
{
90-
// auto_assert not explicitly set — config merge default (false) applies.
91101
$response = $this->get('/v1/pets?bad=1');
102+
$response->assertOk();
103+
104+
$this->assertArrayNotHasKey('petstore-3.0', OpenApiCoverageTracker::getCovered());
105+
}
106+
107+
#[Test]
108+
public function auto_assert_with_invalid_config_value_fails_loudly(): void
109+
{
110+
// Common user mistake: config value is a non-boolean-compatible value
111+
// (e.g. typo in a cast). The trait must surface this as a clear test
112+
// failure rather than silently treating it as "off".
113+
config()->set('openapi-contract-testing.auto_assert', 'definitely-not-a-bool');
114+
115+
$this->expectException(AssertionFailedError::class);
116+
$this->expectExceptionMessage('auto_assert must be a boolean');
92117

118+
$this->get('/v1/pets');
119+
}
120+
121+
#[Test]
122+
public function auto_assert_accepts_string_true_as_truthy(): void
123+
{
124+
// env('X') returns strings — `'auto_assert' => env('AUTO_ASSERT')`
125+
// would yield "true" (not boolean true). FILTER_VALIDATE_BOOLEAN
126+
// treats this as truthy, so the user isn't punished for common
127+
// env-var wiring.
128+
config()->set('openapi-contract-testing.auto_assert', 'true');
129+
130+
$response = $this->get('/v1/pets');
93131
$response->assertOk();
94132

95133
$covered = OpenApiCoverageTracker::getCovered();
96-
$this->assertArrayNotHasKey('petstore-3.0', $covered);
134+
$this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0'] ?? []);
97135
}
98136

99137
#[Test]
100-
public function explicit_and_auto_assert_are_idempotent(): void
138+
public function explicit_assert_after_auto_assert_is_idempotent(): void
101139
{
102140
config()->set('openapi-contract-testing.auto_assert', true);
103141

104-
// Auto-assert runs during $this->get(). A subsequent explicit call on
105-
// the same response must not re-run validation nor re-record coverage.
106142
$response = $this->get('/v1/pets');
107143
$this->assertResponseMatchesOpenApiSchema($response);
108144

109145
$covered = OpenApiCoverageTracker::getCovered();
110146
$this->assertCount(1, $covered['petstore-3.0'] ?? []);
111147
}
112148

149+
#[Test]
150+
#[OpenApiSpec('petstore-3.1')]
151+
public function method_level_attribute_resolves_spec_for_auto_assert(): void
152+
{
153+
// default_spec is set to petstore-3.0 in setUp, but this method is
154+
// decorated with #[OpenApiSpec('petstore-3.1')] — auto-assert must
155+
// respect the attribute and record coverage under 3.1, not 3.0.
156+
config()->set('openapi-contract-testing.auto_assert', true);
157+
158+
$response = $this->get('/v1/pets');
159+
$response->assertOk();
160+
161+
$covered = OpenApiCoverageTracker::getCovered();
162+
$this->assertArrayHasKey('petstore-3.1', $covered);
163+
$this->assertArrayNotHasKey('petstore-3.0', $covered);
164+
}
165+
113166
/** @return array<int, class-string> */
114167
protected function getPackageProviders($app): array
115168
{
@@ -127,5 +180,10 @@ protected function defineRoutes($router): void
127180
: ['data' => [['id' => 1, 'name' => 'Fido', 'tag' => null]]],
128181
);
129182
});
183+
184+
Route::post('/v1/pets', static fn() => response()->json(
185+
['data' => ['id' => 42, 'name' => 'Buddy', 'tag' => null]],
186+
201,
187+
));
130188
}
131189
}

tests/Unit/ValidatesOpenApiSchemaAutoAssertTest.php

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)