diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c032f1..76097f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -151,6 +151,51 @@ jobs: - name: Build run: composer build + upstream: + name: Contract dev-master + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run == 'true') }} + runs-on: ubuntu-latest + # This package and rasuvaeff/openapi-contract are one oracle in two + # repositories: generated traffic is recorded into the contract's corpus, + # so the contract notices a change here. Nothing noticed a change THERE — + # a tightened compiler broke ten tests of this suite and stayed invisible + # until someone ran it by hand. This job is that run, and it may not gate + # the build: the contract's master is allowed to be ahead of what this + # package supports, and a red result is a heads-up, not a verdict. + continue-on-error: true + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.4' + coverage: none + extensions: json, mbstring + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.composer/cache + key: composer-${{ runner.os }}-${{ hashFiles('**/composer.json') }} + restore-keys: composer-${{ runner.os }}- + + - name: Install dependencies with the contract's development branch + run: | + composer config minimum-stability dev + composer config prefer-stable true + composer require rasuvaeff/openapi-contract:dev-master --no-update --no-interaction + composer update --no-interaction --no-progress + + - name: Test + run: composer test + coverage: name: Coverage & Mutation needs: changes diff --git a/AGENTS.md b/AGENTS.md index 38f4bdc..28694ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,6 +219,31 @@ every mutation of it is masked. `RequestReproducer::redactCase()` had four such guards over `$body['encoding']` and became one `match`; the escapes went with them. Masked mutants are a shape, not a fact of life. +## The contract package is the other half of the oracle + +`rasuvaeff/openapi-contract` depends on nothing here, and this package depends +on it — but the two are one oracle: generated traffic is recorded into the +contract's corpus, so a change *here* shows up *there*. The reverse does not +hold, and it has already cost a day: the contract tightened its compiler and +ten tests of this suite went red without anything noticing until someone ran +them by hand. + +Two habits close it: + +- the `Contract dev-master` job in `build.yml` runs this suite against the + contract's development branch on every relevant PR. It is `continue-on-error` + on purpose — the contract's master may legitimately be ahead of what this + package supports — so read it, do not ignore it; +- before releasing either package, run the suite of the other. From the + monorepo root: + `docker run --rm -v "$PWD/property-testing-openapi":/app -v "$PWD":/repo -v "$PWD/docs/reviews/probe-openapi-contract-2026-09-06":/s -w /app composer:2 sh /s/pto-against-master.sh` + +A document this package builds only to prove a refusal must not go through +`Contract::fromArray()` when the contract already refuses it: hand-build the +`Operation`. `forOperation()` and `partContentTypeMismatchForOperation()` take +one by signature, and that is the only path such a shape can still travel in +production. + ## When you finish Run `composer build`, `composer rector`, and `git diff --check`. Run mutation diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3765e..9a672f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +- **Internal.** Two tests built documents `rasuvaeff/openapi-contract` no + longer accepts, so this suite went red against its development branch while + staying green against the release. The shared multipart fixture is a document + that compiles again, and the malformed header declarations it carried — a + value that is not an object, a name that is not a string — moved to a test + that hands the generator an `Operation` built by hand, which is the only way + such a shape can still reach it. `rejectsAPartContentTypeMismatchItCannotConstruct` + builds its operation the same way, so every dataset reaches the refusal it + is named for. + +- **Internal.** A `Contract dev-master` job runs this suite against the + contract's development branch on every relevant pull request. It does not + gate the build — that master may legitimately be ahead of what this package + supports — but the day it goes red is the day to look, instead of finding out + at release time. + ## 0.10.0 — 2026-09-05 - **Added.** `NegativeRequestCaseArbitrary::partContentTypeMismatchForOperation()` diff --git a/tests/RequestCaseArbitraryTest.php b/tests/RequestCaseArbitraryTest.php index 0a7e132..e6f6bc1 100644 --- a/tests/RequestCaseArbitraryTest.php +++ b/tests/RequestCaseArbitraryTest.php @@ -801,6 +801,46 @@ public function avoidsASubstituteTheDeclarationAllows(): void Assert::false($contract->validateRequest($request)->isValid()); } + /** + * A part header declaration this generator cannot read — a value that is + * not an object, a name that is not a string — is skipped rather than + * generated from. + * + * The shape used to live in the shared multipart fixture. It cannot: since + * openapi-contract 0.8 such a document does not compile at all, and the + * only way the shape still reaches a generator is an `Operation` built by + * hand, which `forOperation()` accepts by signature. + */ + public function skipsPartHeaderDeclarationsItCannotRead(): void + { + $operation = new Operation( + key: 'upload.create', + operationId: 'upload.create', + method: 'POST', + path: '/upload', + requestBody: ['required' => true, 'content' => ['multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['title'], + 'additionalProperties' => false, + 'properties' => ['title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 12]], + ], + 'encoding' => ['title' => ['contentType' => 'text/plain', 'headers' => [ + 'X-Malformed' => 'ignored', + 7 => ['required' => true, 'example' => 'seven'], + 'X-Kept' => ['required' => true, 'example' => 'yes'], + ]]], + ]]], + responses: ['201' => []], + ); + + $case = (new RequestCaseArbitrary())->forOperation($operation)->generate(new Random(11))->value; + $parts = $case['body']['parts'] ?? []; + + Assert::same(count($parts), 1); + Assert::same($parts[0]['headers'], ['X-Kept' => 'yes']); + } + /** * Every document the category cannot be built from, each named for its * reason. The misuse rewrites a part a valid case already carries, under a @@ -816,7 +856,7 @@ public function rejectsAPartContentTypeMismatchItCannotConstruct(array $requestB Expect::exception(UnsupportedGeneration::class)->withMessage('Operation "uploads.create" has no required multipart body declaring a part content type'); (new NegativeRequestCaseArbitrary())->partContentTypeMismatchForOperation( - $this->partContract($requestBody)->operation('uploads.create'), + $this->partOperation($requestBody), ); } @@ -886,6 +926,27 @@ private function encodedPartContract(string $contentType = 'text/plain'): Contra } /** @param array $requestBody */ + /** + * Built by hand rather than compiled: several of the bodies above are + * shapes `Contract::fromArray()` refuses outright since openapi-contract + * 0.8, and the refusal under test is this package's. A hand-built + * `Operation` is also the only way such a shape can still reach the + * generator at all, so it is the path worth pinning. + * + * @param array $requestBody + */ + private function partOperation(array $requestBody): Operation + { + return new Operation( + key: 'uploads.create', + operationId: 'uploads.create', + method: 'POST', + path: '/uploads', + requestBody: $requestBody, + responses: ['204' => []], + ); + } + private function partContract(array $requestBody): Contract { return Contract::fromArray(['openapi' => '3.1.0', 'paths' => ['/uploads' => ['post' => [ diff --git a/tests/Support/BodyContracts.php b/tests/Support/BodyContracts.php index b2f32d7..fd95ddc 100644 --- a/tests/Support/BodyContracts.php +++ b/tests/Support/BodyContracts.php @@ -31,9 +31,12 @@ public static function multipart(): Contract 'flag' => ['type' => 'boolean'], ]], 'encoding' => [ + // Only declarations a document may legally carry: the + // shapes `Contract::fromArray()` refuses live in + // RequestCaseArbitraryTest, on an Operation built by + // hand, which is where they can still reach the + // generator. 'title' => ['contentType' => 'text/markdown', 'headers' => [ - 'X-Malformed' => 'ignored', - 7 => ['required' => true, 'example' => 'seven'], 'X-Unspecified' => ['example' => 'u'], 'X-Optional' => ['required' => false, 'example' => 'no'], 'X-Example' => ['required' => true, 'example' => 'yes'],