Skip to content

T19 add rest test case#20

Merged
niden merged 21 commits into
masterfrom
T-19-add-rest-test-case
Jul 15, 2026
Merged

T19 add rest test case#20
niden merged 21 commits into
masterfrom
T-19-add-rest-test-case

Conversation

@niden

@niden niden commented Jul 15, 2026

Copy link
Copy Markdown
Member

Hello!

In raising this pull request, I confirm the following:

  • I have read and understood the Contributing Guidelines
  • I have checked that another pull request for this purpose does not exist
  • I wrote some tests for this PR
  • I have updated the relevant CHANGELOG
  • I have created a PR for the documentation about this change

Added a REST/JSON test surface: Phalcon\Talon\Traits\RestTrait (the full verb set, file uploads, request headers that persist across requests, amBearerAuthenticated()/amHttpAuthenticated(), redirect control, and response grabbers), Phalcon\Talon\Traits\RestAssertionsTrait (status, range, body, header, and JSON assertions), and Phalcon\Talon\PHPUnit\AbstractRestTestCase which composes both. A raw string body is sent as application/json unless a content type is set, and $files takes either a plain path or the $_FILES shape.

Added Phalcon\Talon\Http\HttpCode - HTTP status constants plus getDescription(), which returns the 404 (Not Found) form. It is deliberately an independent implementation of the standard reason phrases rather than a lookup into the application under test, so that asserting an application's emitted status string against it actually asserts something.

Added Phalcon\Talon\Http\JsonType - validates a decoded JSON document against a map of type expectations (string, integer, float, boolean, array, null, the :date filter, | unions, and nested maps). Keys absent from the map are ignored, so a map can describe just the part of an envelope a test cares about. float accepts a whole number too - JSON has a single number type, so {"price": 10} decodes to an int and a strict float would fail on every round value. integer stays strict.

Added Phalcon\Talon\Http\JsonSubset - recursive subset matching, so a fragment can be asserted against a full document. Keys and list elements present in the response but absent from the expectation are ignored, and list elements match in any order. An empty expected list is the exception: ['data' => []] asserts that data is empty rather than matching any data at all.

Added TALON_REST_URL to Settings::fromEnv(), readable via Settings::get('rest_url') and defaulting to http://127.0.0.1:8080. It is resolved through Talon's shared Settings and so is the same for every test in a run; RestTrait::useRestBaseUrl() points an individual test somewhere else.

Thanks

niden added 20 commits July 15, 2026 10:50
Talon was the only project without ordered_class_elements; vokuro, invo,
phalcon, cphalcon, volt and phql all carry a byte-identical block. Its
absence is why class members here were never ordered by visibility and
name the way the standard describes.

declare_strict_types and no_unused_imports are kept as a local addition -
phpcs (PSR-12) checks neither, so dropping them to match the shared set
exactly would leave both unenforced. They should move into the shared set
once the global standard is agreed.
Pure formatting: no behaviour changes. This is the one-off cost of adding
the rule - every subsequent change is kept in order by composer cs-fixer-fix
rather than by hand.

Reordering moves members past each other, which leaves PSR-12 blank-line
artifacts that php-cs-fixer does not own; phpcbf (composer cs-fix) settled
those. Run the two in that order after any future reorder.
The REST surface needs a base URL. It goes in the extra bag rather than
services because the fromEnv() service table is host/port-shaped and a URL
does not fit it. CODECEPTION_URL/CODECEPTION_PORT are deliberately not
carried over.
Both are needed by Symfony\Component\BrowserKit\HttpBrowser, which the REST
surface uses to make real requests. browser-kit lists them as require-dev,
not require, so neither arrives transitively.

Mime is not only for file uploads: HttpBrowser::getBodyAndExtraHeaders()
raises 'You cannot pass non-empty bodies as the Mime component is not
installed' for every method other than GET/HEAD, and it checks that before
it looks at the body at all - so even a PUT with no parameters fails
without it.

Constrained to ^6.4 || ^7.0 to match browser-kit and dom-crawler. Symfony
7.x requires PHP 8.2, so on our 8.1 floor composer resolves the 6.4 LTS
line. psr/container, psr/log, deprecation-contracts and service-contracts
move from packages-dev to packages at unchanged versions, because
http-client requires them at runtime.
getDescription() returns the '404 (Not Found)' form. It is deliberately an
independent implementation of the standard reason phrases rather than a
lookup into the application under test: rest-api emits that exact string in
its JSON error payload from its own table, so an assertion comparing its
output against its own source would assert nothing.
Keys and list elements present in the response but absent from the
expectation are ignored, so a fragment can be asserted against a full
JSON:API envelope. List elements match in any order; scalars compare
strictly.
Validates a decoded document against a map of types rather than values:
string, integer, float, boolean, array, null, the :date filter, | unions,
and nested maps. Keys absent from the map are ignored - the envelope check
names only jsonapi and meta while the document also carries data. Types are
strict, so an int does not satisfy float.

Scoped to what is needed plus the obvious neighbours; Codeception's JsonType
carries a broader DSL that is not worth cloning.
Drives a BrowserKit HttpBrowser over symfony/http-client, so requests cross
a real HTTP boundary. The in-process Browser\Client cannot serve here: it
never maps request headers into $_SERVER (rest-api reads its JWT via
getHeader('Authorization')), and a raw JSON body cannot reach php://input
in-process.

restHttpClient() is the transport seam. It returns null so HttpBrowser
builds a real client; a test overrides it with a MockHttpClient and drives
the same request-building path without a live server.

Request headers are tracked in the trait and re-pushed through
setServerParameters() on every change, because BrowserKit has no
removeServerParameter() - rebuilding the whole set is the only way to drop
one, which unsetHttpHeader() needs.

$files is not carried over from Codeception's signature: rest-api uploads
nothing and no use case needs it yet.
Mirrors the BrowserTrait/BrowserAssertionsTrait/AbstractBrowserTestCase
split: actions read as verbs, assertions are assertX, and the assertions
trait reaches the response through abstract declarations that RestTrait
satisfies.

AbstractRestTestCase needs no $_SESSION isolation, unlike
AbstractBrowserTestCase - requests cross a real HTTP boundary, so there is
no in-process session to leak between tests.
Coverage was already 100%, but coverage only proves a line ran - infection
showed 84 mutants surviving in the new code.

The largest group was PublicVisibility: reducing a public assertion to
protected broke nothing, because the tests called it via $this from inside
the class. Fakes/Rest/PublicApiConsumer follows the existing
Fakes/Browser/PublicApiConsumer precedent and exercises the surface from
outside the hierarchy, which is what actually pins the visibility.
DefaultSeamConsumer exists separately because PublicApiConsumer overrides
both seams, and an override masks whether the trait's own definitions are
still reachable from a child.

The rest were tests asserting only the happy path: removing the inner
assertion of an assert*() left every test passing, and the range bounds were
never pinned from outside (a test using 404 cannot tell 400..499 from
401..498). Each assertion now has a failing case, and each bound is pinned
from both sides.
Two changes.

The line references for Settings::getDatabaseDsn were stale: applying
ordered_class_elements moved the method from 186 to 246, so the MatchArmRemoval
and Throw_ ignores pointed at unrelated lines. Any line-scoped ignore in a
file the reorder touched had to be rechecked; these two were the only ones
that moved. Worth knowing for next time - line-scoped ignores do not survive
reordering, which is why the new entries below are method-scoped.

Four new ignores for genuinely unkillable mutants, each unobservable from a
test rather than merely inconvenient to cover: a break that only short-circuits
a scan, an explode() limit the DSL can never reach, and two casts that are
no-ops on every path that can observe them.
infection requires thecodingmachine/safe at dev-master, which autoloads 80
files eagerly - they land in vendor/composer/autoload_files.php, so they are
parsed on every require of the autoloader, not only when infection runs. On
PHP 8.5 that emits deprecations across the whole matrix rather than just the
one cell mutation testing uses.

The lock happened to pin safe at 4fbc008, an older dev-master commit that
still behaves, so this had not surfaced here yet. dev-master is a moving
target and dependabot is active, so it was a matter of time.

resources/infection.json5 and the test-mutation script stay, as does the
extension-installer allow-plugin entry, so the setup comes back with a single
composer require --dev. The CI step is commented out rather than deleted for
the same reason.

Mutation testing was run before this removal: MSI 99%, with no escaped
mutants in the REST surface. Revisit once the minimum PHP version moves past
8.1 and infection can be brought back.
Raw string bodies went out as text/plain. HttpBrowser wraps an unlabelled
string body in a TextPart, so sendPost($url, json_encode($data)) - the most
natural call on this surface - silently mislabelled every request. String
bodies now default to application/json, per request, and only when the caller
has not named a type. The test asserted only the body, which is how a path at
100% coverage and 99% MSI still shipped wrong: the line ran and the mutants
died, but nothing looked at the header.

Request headers now ride the per-request $server argument instead of
setServerParameters(). BrowserKit merges that over its own bag rather than
replacing it, so a caller's setServerParameter() survives. The old approach
rebuilt the whole bag on every header change and silently dropped anything it
had not set - aftermath of needing an unset that BrowserKit has no API for.
syncRequestHeaders() is gone with it, and so is the ordering subtlety where
restBrowser() had to sync after constructing.

useRestBaseUrl() makes the base URL injectable rather than only overridable.
It was reachable solely through Talon's process-global Settings, which is why
PublicApiConsumer had to override the seam - and that override is what masked
the visibility mutants and forced a second fake.

$files is back, now that mime is a dependency and the reason for dropping it
is void. It takes a plain path as well as the $_FILES shape: BrowserKit's
answer to a plain path is to stop reading the list and send nothing, silently,
which is a poor thing to hand someone.
['data' => []] asserted nothing. Read as a pure subset an empty list matches
every document, so the fragment only proved the key existed - and rest-api's
seeSuccessJsonResponse() defaults straight into that shape, so the conversion
would have inherited a suite of assertions that could not fail. An empty
expected list now means empty. An empty fragment at the top level is rejected
outright, since it can only be a caller mistake.

'float' rejected whole numbers. JSON has a single number type, so json_decode
returns int for {"price": 10} and float for {"price": 10.5} - the same field
is one or the other depending on the value it carries, and a strict 'float'
fails on every round one. It accepts both now; 'integer' stays strict.
Two entries had gone stale against the code: JsonType claimed strict types
(float now accepts a whole number) and JsonSubset claimed absent list elements
are always ignored (an empty expected list now means empty). File uploads and
useRestBaseUrl() were added after the entries were written and were missing.
match() scored 17 against Sonar's rules, over the default threshold of 15. It
was traversing the map, checking key presence, dispatching leaf-vs-nested and
formatting error text all at once, and the nesting penalty did most of the
damage - two branches cost +3 each purely for sitting two levels deep.

The tell was the guard reading

    !is_string($expected) || !self::matchesSpec($value, $expected)

which collapsed two distinct failures into one branch, then re-evaluated
is_string() inside the sprintf to work out which one it had caught. matchValue()
keeps them apart so each reports itself. path() and typeError() take the
remaining inline work out of the loop, and the continue goes with it.

match() is now 6; nothing in the file is above it. Behaviour is unchanged - the
existing tests carried the refactor untouched.

Mutation testing also showed 'float' accepting a string, because negating both
operands of is_float() || is_int() is unobservable without a non-number case.
The upload tests uploaded __FILE__, so the multipart body embedded the test's
own source - which contains every string the assertions look for. They passed
whatever was actually sent, including when only the first of several files was.
They now post a fixture whose contents cannot collide, and assert on the
Content-Disposition filenames.

The form-body test asserted 'username' and 'sarah' appear in the body; both
appear in a JSON body too, so it could not tell the form path from the JSON
one. It pins the urlencoded body now.

useRestBaseUrl() had no caller outside the class hierarchy, so nothing held its
visibility. The empty-fragment message was asserted by a pattern matching only
its first half; it is one literal now, with nothing to reorder or drop.
@niden
niden requested a review from Jeckerson July 15, 2026 19:38
@niden niden self-assigned this Jul 15, 2026
@niden niden added the enhancement New feature or request label Jul 15, 2026
@niden niden linked an issue Jul 15, 2026 that may be closed by this pull request
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

Copy link
Copy Markdown

Code Metrics Report

master (ee35373) #20 (6b856eb) +/-
Coverage 100.0% 100.0% 0.0%
Test Execution Time 1s 2s +1s
Details
  |                     | master (ee35373) | #20 (6b856eb) | +/-  |
  |---------------------|------------------|---------------|------|
  | Coverage            |           100.0% |        100.0% | 0.0% |
  |   Files             |               46 |            52 |   +6 |
  |   Lines             |              777 |           959 | +182 |
+ |   Covered           |              777 |           959 | +182 |
- | Test Execution Time |               1s |            2s |  +1s |

Code coverage of files in pull request scope (100.0% → 100.0%)

Files Coverage +/- Status
src/Contracts/Settings.php 0.0% 0.0% modified
src/Http/HttpCode.php 100.0% +100.0% added
src/Http/JsonSubset.php 100.0% +100.0% added
src/Http/JsonType.php 100.0% +100.0% added
src/PHPUnit/AbstractBrowserTestCase.php 100.0% 0.0% modified
src/PHPUnit/AbstractFunctionalTestCase.php 0.0% 0.0% modified
src/PHPUnit/AbstractRestTestCase.php 0.0% 0.0% added
src/PHPUnit/AbstractUnitTestCase.php 100.0% 0.0% modified
src/Settings.php 100.0% 0.0% modified
src/Traits/BrowserTrait.php 100.0% 0.0% modified
src/Traits/FunctionalAssertionsTrait.php 100.0% 0.0% modified
src/Traits/FunctionalTrait.php 100.0% 0.0% modified
src/Traits/RestAssertionsTrait.php 100.0% +100.0% added
src/Traits/RestTrait.php 100.0% +100.0% added

Reported by octocov

@niden
niden merged commit b8557e7 into master Jul 15, 2026
22 checks passed
@niden
niden deleted the T-19-add-rest-test-case branch July 15, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[NFR]: Add REST test case

1 participant