Skip to content

[3.0][Testing] Cover last week's fixes that the unit suite can reach - #9511

Open
albertlast wants to merge 19 commits into
SimpleMachines:release-3.0from
albertlast:tests/regressions
Open

[3.0][Testing] Cover last week's fixes that the unit suite can reach#9511
albertlast wants to merge 19 commits into
SimpleMachines:release-3.0from
albertlast:tests/regressions

Conversation

@albertlast

@albertlast albertlast commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Note

This change was produced by an LLM. The tests, the commit message and this
description were all written by Claude (Anthropic), driven by @albertlast. It has
not yet had human code review.

The suite was run rather than only reasoned about, and every test below was also
run against the unfixed code it covers. Please review it as untrusted work.

Description

This is the first instalment of what #9326 was for: a sweep of the bug fixes merged
into release-3.0 over the past week, asking of each one whether the unit suite can
reach it, and writing a test where it can.

Twenty-five or so fixes landed in that window. Most of them are templates, JavaScript,
or SQL, and most of the PHP ones need Db::$db or User::$me. Three do not.

Fix What is now covered
#9506 IP::ip2range() on IPv6 ranges, written both elided and in full, plus IPv4 and the wildcard forms as the control
#9507 Url::proxied() refusing a private, loopback or documentation IPv6 host written in brackets, and still proxying a global one
#9499 TimeInterval being a \DateInterval rather than holding one: \DateTime::add() and ::sub(), the %a fallback, fractional seconds, and the string form

IP::matchToCIDR() is covered too. #9507 restructured it without meaning to change it,
and those cases pass either side of that commit — which is the point of having them.

140 tests, 206 assertions, still well under a second.

These are regression tests, not tests written to fit

Each set was run against the code as it was before its fix, by checking out the single
file at the commit before the merge.

  • Sources/IP.php before [3.0] Makes SMF\IP::ip2range() work correctly with IPv6 addresses #9506: both IPv6 range cases fail, and the failure is worth
    reading. 2001:db8::1-2001:db8::ff came back as 255.255.255.255 for the low end,
    because neither end validated as IPv4, so the range was not recognised as a range and
    the address was walked as though it were a fragment. A ban on an IPv6 range was
    landing somewhere else entirely.
  • Sources/Url.php before [3.0] Improves handling of IPv6 hosts in SMF\IP and SMF\Url #9507: the three private IPv6 cases fail, each by producing a
    proxy.php URL. http://[::1]/pic.png was being handed to the proxy, which is the
    forum fetching its own loopback on behalf of whoever pasted the address. The IPv4
    cases pass either side, as they should.
  • Sources/TimeInterval.php before [3.0] Restores full compatibility of SMF\TimeInterval with \DateInterval #9499: the two date-arithmetic cases do not fail so
    much as stop, with
    DateObjectError: Object of type SMF\TimeInterval (inheriting DateInterval) has not been correctly initialized by calling parent::__construct().
    Four more fail on the string form.

Two existing tests changed

testTimeOnlyDurationsKeepTheirTimeDesignator and testStringifyingIsStableAcrossARoundTrip
asserted P0DT30M. #9499 removed the behaviour behind that — the class no longer
populates days for a duration naming no years or months, and no longer writes a zero
day count out — so both now assert PT30M, which is the shorter and more conventional
form. A third case, testAZeroUnitIsDroppedOnTheWayBackOut, keeps P0DT30M as input
so the normalisation itself stays watched.

This is the suite doing its job on the first change to reach it: #9499 was a deliberate
behaviour change, and the two failures said so.

What is deliberately not here

Two of the week's fixes looked reachable and are not:

Both belong to an integration suite. Neither is a reason to contort the production code.

The rest of the week was out of reach for the ordinary reasons: #9433, #9459, #9463,
#9455, #9466, #9482 and #9476 need a database; #9453 reaches Db::$db through
PageIndex's constructor; #9449, #9444 and #9454 are templates; #9479, #9494 and #9495
are JavaScript; #9505 needs a socket; #9502 sits inside a protected method on a loaded
Profile.

One defect found while writing these

IP::matchToCIDR() builds its IPv6 mask like this:

$bin_mask = str_repeat('f', (int) $cidr_subnetmask / 4);

The cast binds to $cidr_subnetmask, not to the division, so the argument is a float
whenever the prefix length is not a multiple of four, and str_repeat() throws a
TypeError. matchToCIDR('2001:db8::/33') is a fatal error, not a false.

The switch ($cidr_subnetmask % 4) immediately below it exists to add the odd nibble
for exactly those prefix lengths, so all three of its cases are unreachable and always
have been. The line predates #9507, which renamed the variable on it; it came in with
9cc402c99, moving the method over from QueryString::matchIPtoCIDR().

Nothing here fixes it. The data provider keeps to multiples of four and says why in a
comment, because asserting the TypeError would preserve it. Worth its own PR.

How this was verified

  • vendor/bin/phpunit: OK (140 tests, 206 assertions) on PHP 8.4.24.
  • Each new group re-run against the pre-fix file, as described above.
  • php-cs-fixer check --allow-risky=yes tests/: Found 0 of 13 files that can be fixed.
  • check-signed-off.php passes locally.

A word on the three lines of Sources/ in this diff

They are #9512, merged in, and they are not this branch's doing.

.github/workflows/php-cs-fixer.yml restricts the check to the files a pull request
touched, unless that pull request changes .php-cs-fixer.dist.php or composer.lock,
in which case it scans everything. This branch changes composer.lock, because #9326
adds PHPUnit, so it got the whole-tree scan and reported three pre-existing violations
of the SMF/section_comments fixer in Alert.php, Autolinker.php and Help.php
none of which this branch goes anywhere near. They are one blank line added and two
removed, and they have been sitting in release-3.0 unnoticed because no pull request
since has touched those files or triggered a full scan.

#9512 fixes them against release-3.0 and is worth merging on its own. It is merged
here so this branch is green now rather than green later; merge #9512 first and it
disappears from this diff.

Relationship to other PRs

This is stacked on #9326 and contains it, so the diff shown here is that branch's as
well as its own. It also therefore carries everything #9326 carries: #9317, #9321,
#9324 and #9325, plus #9512 as described above. Merge those and rebase this on
release-3.0, and only tests/ is left.

The three fixes it covers — #9499, #9506, #9507 — are already in release-3.0, which is
merged into this branch.

Issues References (Fixes|Related|Closes)

  1. Depends on: [3.0][Testing] Add a PHPUnit suite for the parts that need no database #9326 (and through it [3.0][Testing] Add a Docker development environment for MySQL and PostgreSQL #9317, [3.0] Give each action subclass its own instance in ActionTrait::load() #9321, [3.0] Parse memory settings that carry no unit designator #9324, [3.0] Compare URL schemes case insensitively #9325), [3.0] Restore the blank line the section banners want around them #9512
  2. Related: [3.0] Restores full compatibility of SMF\TimeInterval with \DateInterval #9499, [3.0] Makes SMF\IP::ip2range() work correctly with IPv6 addresses #9506, [3.0] Improves handling of IPv6 hosts in SMF\IP and SMF\Url #9507
  3. Related: [3.0] Correctly restores original content of code BBC in MarkdownParser #9509 (out of reach for this suite, noted above)

albertlast and others added 18 commits July 29, 2026 18:22
ActionTrait declares $obj as a static property, and a static property is
shared with every descendant class that does not redeclare it. None of
the eleven action classes that extend another action redeclare it, so
they all share one slot with their parent.

Once the parent has been loaded, load() finds that slot occupied and
returns the parent's instance, which does not satisfy the "static"
return type:

    SMF\Actions\Login2::load(): Return value must be of type
    SMF\Actions\Logout, SMF\Actions\Login2 returned

This is reachable during login: User::enforceBans() calls Logout::call()
to kick a banned member, by which point Login2 has already been loaded,
so a banned member gets a fatal error instead of being logged out.

Checks that the cached instance is of the class being loaded, rather than
merely present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SMF has no automated tests. CI proves that the code parses and that it is
formatted; it never executes anything. Every one of the bugs fixed in
SimpleMachines#9319 through SimpleMachines#9322 parsed cleanly and passed every check.

Quite a lot of 3.0 is reachable without a forum behind it. The bootstrap
here defines the constants index.php would define and points the
autoloader at Sources/, and that is enough: no Settings.php, no database,
no request. Anything that reaches Config::$modSettings, User::$me or
Db::$db is out of scope and belongs in an integration suite.

The first tests cover ground that recently broke:

- ActionTrait::load() returning an instance of the class it was called
  on, in both orders and in two separate class hierarchies.
- CreatePost_Notify::getTimeOffset(), including the half-hour and
  quarter-hour zones that an int cast used to truncate.
- Utils::buildRegex(), including the trailing quoted character from
  SimpleMachines#9318.

tests/ is already excluded from the license header check in BuildTools,
and the directory index.php files keep check-smf-index happy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a PHPUnit workflow across the same 8.4 and 8.5 matrix the syntax
check already uses, and a composer test script.

Two adjustments fall out of running the suite next to the existing
checks. The PHPUnit cache lives in .phpunit.cache rather than under
cache/, because check-smf-index walks every directory that is not
hidden and would otherwise report a missing index file the moment
anyone runs the tests locally. And AGENTS.md no longer says there is no
test suite; it now says what the suite does and does not cover, so an
agent does not mistake a green run for proof that a change works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass covered only the three things that had recently broken.
Quite a lot more is reachable without a database once the bootstrap sets
the paths and default language that the Unicode and entity helpers use to
find their data files, which is six lines and still reads nothing from
Settings.php.

Adds coverage for Utils' entity-aware string handling and Unicode case
conversion, IP, Url, Uuid, Sapi, Security's password hashing, Punycode
and TimeInterval. 99 tests, 144 assertions, on 8.4 and 8.5.

Two behaviours are deliberately described rather than asserted, because
pinning them down would preserve something that looks wrong:

- Sapi::memoryReturnBytes() strips the last character before parsing, so
  a unit-less value such as '128' reads as 12 and the '-1' that means "no
  limit" reads as 0. Only suffixed values are asserted.
- Url::isScheme() compares the scheme without normalising case, so an
  uppercase scheme fails to match its own name. Only exact-case matching
  is asserted.

IP's constructor accepts the packed binary form, which it cannot tell
apart from any other 4 or 16 byte string, so 'nope' becomes
110.111.112.101. That one is genuine ambiguity rather than a defect, so
it is pinned down as a test in its own right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
memoryReturnBytes() removed the last character of the value before
parsing the number, on the assumption that it is always a designator.
PHP's shorthand notation is optional, so a plain byte count loses its
last digit: '128' reads as 12, and '2097152' reads as 209715.

Graphics\Image does exactly that, passing a computed byte count with no
designator, so resizing an image asks for a tenth of the memory it just
worked out that it needs.

The other value with no designator is '-1', which means there is no
limit. It read as 0, because intval('-') is 0, so setMemoryLimit() found
the current limit to be smaller than anything and set one. On a server
with no memory limit, asking for 128M capped it at 128M.

Only strips the last character when it is one of the designators PHP
accepts, and reports "no limit" as PHP_INT_MAX so that the callers
comparing it against an amount they need do not each have to special
case it.

The dead is_integer() check went with it; the parameter is typed string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RFC 3986, section 3.1, makes scheme names case insensitive, and this
class keeps the scheme exactly as it was written rather than normalizing
it. isScheme() compared the two with in_array(), so a URL written with an
uppercase scheme did not match its own name.

That reaches two callers. isWebsite() stops recognising HTTP:// and
HTTPS:// as websites, and the avatar handling in User treats a DATA: URI
as though it were a remote address.

Folds both sides before comparing, and makes the comparison strict while
it is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The memoryReturnBytes() and isScheme() cases were described in comments
rather than asserted, because pinning down the behaviour would have
preserved it. Now that both are fixed, they become tests.

Verified to fail against the unfixed code: reverting the two source files
alone fails exactly these six tests and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CoversClass on a trait is not a valid coverage target, and PHPUnit only
says so when coverage is actually collected. The suite passed on its own
and failed all five ActionTrait cases the moment anyone ran it with
--coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>

# Conflicts:
#	AGENTS.md
The testing notes described the suite mainly as a limitation, which left agents
with no way to tell whether the code in front of them was reachable from it. Sets
out the expectation that a reachable change carries a test, and lists the cases
that work with the examples already in tests/Unit/: pure helpers, value objects,
class-level behaviour, protected helpers through reflection, and modSettings keys
the test sets itself. Also names the strict-mode traps and the two ways the style
fixer rearranges a test file.

Corrects the CI claim as well; phpunit.yml only runs on pull requests and on
pushes to release-3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Three of the bug fixes merged into release-3.0 over the past week touch code
that needs no database, so they can be pinned down here rather than only in a
running forum.

SimpleMachines#9506 stopped SMF\IP::ip2range() validating the two ends of a range as IPv4
only, which meant an IPv6 range was not recognised as a range at all and came
back as 255.255.255.255.

SimpleMachines#9507 made SMF\Url::proxied() strip the brackets off an IPv6 host before
asking filter_var() about it. Without that, every literal IPv6 host read as a
name rather than an address, so the private and reserved ranges were not
excluded and the proxy would fetch them.

SimpleMachines#9499 stopped SMF\TimeInterval holding a \DateInterval of its own and started
being one, which is what \DateTime::add() and ::sub() read. The two cases here
that asserted the old canonical string form are updated: a duration naming no
years or months no longer carries a zero day count through stringification.

Also covers SMF\IP::matchToCIDR(), which SimpleMachines#9507 restructured without meaning to
change it. Those cases pass either side of that commit, which is the point.

Three notes on what is deliberately absent. TimeInterval::localize() goes
through Lang::getTxt(), which loads a language file and so wants Theme and
Db::$db. MarkdownParser, for SimpleMachines#9509, builds its parent, which calls
User::setMe(). Neither is reachable from this bootstrap.

The third is a defect rather than a boundary. matchToCIDR() builds its IPv6
mask with str_repeat('f', (int) $cidr_subnetmask / 4), where the cast binds to
the subnet mask rather than to the division, so any prefix length that is not
a multiple of four hands str_repeat() a float and throws a TypeError. The
switch immediately below it, which exists to add the odd nibble for exactly
those prefixes, has therefore never run. The data provider says so in a
comment and stays on multiples of four: asserting the TypeError would only
preserve it.

Signed-off-by: Mathias Alberts <mathiaspapealbert@hotmail.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Three files are out of step with the SMF/section_comments fixer: Alert.php is
missing the blank line before a banner, and Autolinker.php and Help.php each
carry an extra one.

Nothing has been checking. The workflow asks tj-actions/changed-files what the
pull request touched and, unless that list contains .php-cs-fixer.dist.php or
composer.lock, passes it to the fixer as --path-mode=intersection. A pull
request that touches neither of those and does not touch these three files
never looks at them, and that has been every pull request for some time.

Found by a branch that does change composer.lock, which is what turns the
check back into a whole-tree scan.

Signed-off-by: Mathias Alberts <mathiaspapealbert@hotmail.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
@jdarwood007 jdarwood007 added this to the 3.0 Alpha 5 milestone Aug 13, 2026
@jdarwood007 jdarwood007 added Unit Testing Meta Repository tools labels Aug 13, 2026
@jdarwood007
jdarwood007 requested a review from live627 August 13, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Meta Repository tools Unit Testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants