Skip to content

[3.0] Add a PHPUnit suite for the parts that need no database - #9326

Open
albertlast wants to merge 17 commits into
SimpleMachines:release-3.0from
albertlast:tests/phpunit
Open

[3.0] Add a PHPUnit suite for the parts that need no database#9326
albertlast wants to merge 17 commits into
SimpleMachines:release-3.0from
albertlast:tests/phpunit

Conversation

@albertlast

@albertlast albertlast commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Note

This change was produced by an LLM. The code, the commit messages 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, on both PHP versions CI targets.
Please review it as untrusted work, and as a proposal rather than a fix: whether SMF
wants a test suite, and whether this is the right shape for one, is a project
decision.

Description

SMF has no automated tests. CI proves that the code parses and that it is formatted;
it never executes anything. Every bug in the recent run — #9319, #9320, #9321, #9322,
#9324, #9325 — parsed cleanly and passed every check.

More of 3.0 is testable than the absence of a suite suggests. The 3.0 rewrite into
namespaced classes did most of the work already. tests/bootstrap.php defines the
constants index.php defines, points the autoloader at Sources/, and sets the paths
and default language that the Unicode and entity helpers use to find their data files.
That is the whole bootstrap: no Settings.php, no database, no request.

108 tests, 157 assertions, in well under a second.

Test class Covers
UtilsTest buildRegex, entity-aware strlen/substr/strpos/split, htmlTrim, truncate, shorten, normalize, convertCase, sanitizeChars, sanitizeEntities, JSON
IPTest v4 and v6 normalisation, family flags, binary and hex round trips, validity
UrlTest component parsing, toAscii/toUtf8, IDN, scheme handling
UuidTest v4 and v7, variant, nil, compress/expand, short form, strict parsing
SecurityTest bcrypt round trip, salting, cost factor, generators
SapiTest canonicalPath, memory size parsing
TimeIntervalTest ISO 8601 round trip, calendar-aware toSeconds
ActionTraitTest load() returning the right class, in both orders, two hierarchies
CreatePostNotifyTest getTimeOffset(), including half and quarter hour zones
PunycodeTest IDN round trip

What is not covered is anything reaching Config::$modSettings, User::$me or
Db::$db, which is most of the forum. That boundary is deliberate and is stated in
tests/bootstrap.php: work needing those belongs in an integration suite running
against a real install, which the Docker environment in #9317 would be the natural
place to build. Production code should not be contorted to make it reachable from here.

AGENTS.md, added in #9323, said there was no test suite. It now describes what the
suite does and does not cover, so an agent does not read a green run as proof that a
change works.

These are regression tests, not tests written to fit

Each set was checked against the unfixed code:

  • Reverting only Sources/ActionTrait.php and Sources/Tasks/CreatePost_Notify.php:
    Tests: 16, Assertions: 12, Errors: 11. The four ActionTraitTest cases and all
    seven CreatePostNotifyTest cases fail; the Utils cases pass, because [3.0] Ensures trailing chars are correctly quoted in Utils::buildRegex() #9318 is
    already merged.
  • Reverting only Sources/Sapi.php and Sources/Url.php: exactly six failures, three
    in SapiTest and three in UrlTest, and nothing else.

Two behaviours were found while writing this and are now fixed in #9324 and #9325.
Until they were fixed the suite described them in comments rather than asserting them,
since pinning a defect down in a test preserves it.

One behaviour is pinned down deliberately, in testAnyFourByteStringIsReadAsAPackedAddress:
IP's constructor accepts the packed binary form and cannot tell it apart from any
other four or sixteen byte string, so new IP('nope') is 110.111.112.101 and passes
validation. That is genuine ambiguity in the input rather than a defect, and it is the
sort of thing worth having written down. It caught the test out first: the initial
invalid-input case was 'not an ip at all', which is exactly sixteen bytes and
therefore a valid IPv6 address.

How this was verified

  • vendor/bin/phpunit: OK (108 tests, 157 assertions) on PHP 8.4.23 and on 8.5,
    the two versions the existing syntax check targets.
  • All four integrity scripts in the build job still pass: check-smf-license,
    check-smf-index, check-smf-languages, check-version.
  • PHP-CS-Fixer over the whole tree: Found 0 of 1738 files that can be fixed. The
    tests/ directory is inside the fixer's finder, so the tests follow the same rules
    as the rest of the codebase.
  • The forum still serves normally with the branch checked out.

Two practical notes for whoever reviews the plumbing:

  • tests/ is already in the license-header ignore list in BuildTools, so the test
    files need no header. check-smf-index does still walk the directory, so tests/
    and tests/Unit/ carry the standard index.php.
  • PHPUnit's cache is set to .phpunit.cache rather than the default under cache/,
    because check-smf-index reports a missing index file for it the moment anyone runs
    the tests locally. CI would not have caught that, since the jobs do not share a
    workspace.

Separately, the custom SMF/section_comments fixer is not attribute-aware: when the
first member of a group carries a PHP attribute, the banner is inserted between the
attribute and the declaration. tests/Unit/CreatePostNotifyTest.php orders a plain
method first to avoid it, with a comment saying why. Not addressed here, but worth
knowing as attributes become more common.

Coverage

Measured with pcov on PHP 8.4.23, against the whole of Sources/, which is what
<source> in phpunit.xml.dist declares:

Classes:  0.00% (0/597)
Methods:  1.11% (40/3596)
Lines:    0.31% (625/200176)

That headline number is deliberately not flattering. The denominator is the entire
codebase, and this suite only reaches the stateless part of it, so 0.31% is a fair
description of how much of SMF is under automated test today. It is reported here
rather than tuned, because a scope narrowed to "the files that happen to have tests"
would produce a much nicer figure that tells nobody anything.

Per class actually exercised:

Class Lines Methods
Punycode 80.30% (159/198) 21.43% (3/14)
TimeInterval 64.14% (93/145) 33.33% (2/6)
Url 34.82% (78/224) 16.67% (3/18)
Uuid 34.15% (84/246) 25.00% (6/24)
ActionTrait 33.33% (4/12) 20.00% (2/10)
Sapi 20.00% (36/180) 12.50% (2/16)
Utils 19.18% (145/756) 23.21% (13/56)
IP 4.91% (11/224) 22.22% (4/18)
CreatePost_Notify 2.04% (8/393) 16.67% (1/6)
Security 1.64% (7/428) 23.53% (4/17)

Where the line percentage is low but the method percentage is not, the class is mostly
database-bound: CreatePost_Notify at 2% means getTimeOffset() is covered and the
other 385 lines need a running forum.

The number worth watching is not the total. It is whether a class that could be tested
has been.

Three practical notes:

  • The workflow runs with coverage: none, so CI does not collect it. The suite is
    meant to stay fast, and there is no threshold to enforce against yet. Collecting it
    is a local, on-demand thing.
  • Coverage needs a driver that is not installed by default. pecl install pcov in a
    throwaway php:8.4-cli container is enough.
  • It also needs memory_limit well above the 128M default. Building the coverage
    target map over 200k lines exhausts that; 2G is comfortable.

Running it also turned up a defect in this branch, now fixed. #[CoversClass] on
ActionTrait is not a valid target, because it is a trait, and PHPUnit only says so
when coverage is actually collected. The suite passed on its own and failed all five
ActionTraitTest cases the moment anyone added --coverage:

Class SMF\ActionTrait is not a valid target for code coverage

It is #[CoversTrait] now, which is also why ActionTrait appears in the table above.

Relationship to other PRs

This branch is stacked. It contains, and therefore depends on:

The four fix PRs are all independent of this one and can be merged on their own. This
one should go last.

Issues References (Fixes|Related|Closes)

  1. Depends on: [3.0] 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] Stop selecting the dropped time_offset column in CreatePost_Notify #9322, [3.0] Add AGENTS.md instructions for AI coding agents #9323, [3.0] Parse memory settings that carry no unit designator #9324, [3.0] Compare URL schemes case insensitively #9325

albertlast and others added 14 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>
The members table lost its time_offset column in 3.0, in the
DropTimeOffset migration, and User::$time_offset became a virtual
property derived from the member's time zone. User::setProperties() even
lists time_offset among the "obsolete data" it ignores.

CreatePost_Notify still asked the database for it:

    mem.smiley_set, mem.time_format, mem.time_offset, mem.timezone,

so the query fails outright with "column mem.time_offset does not
exist", and the task dies on the fetch_assoc() of a false result. The
task is never marked done, so it is retried on later page loads and
takes down whichever request happens to run it:

    SMF\Db\APIs\PostgreSQL::fetch_assoc(): Argument #1 ($result) must be
    of type object, false given

Nobody watching a board or topic receives their notification either.

Derives the offset from mem.timezone, which the query already selects,
the same way User::$time_offset does. Also keys the parsed-message cache
by time zone rather than by offset, and drops an (int) cast that
truncated the offsets of half-hour and quarter-hour time zones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PHP-CS-Fixer sorts class members, and a static method does not belong in
the non-static "Internal methods" group. No change to the code itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md is the cross-tool convention, read directly by Copilot, Codex,
Cursor, Gemini CLI and others. Claude Code reads only CLAUDE.md, so that
file imports AGENTS.md rather than duplicating it. A symlink would also
work, but not on Windows without Developer Mode, and .gitattributes
already forces LF for Windows checkouts, so the import is the portable
choice.

Contents are derived from the repository's own configuration rather than
from habit: .editorconfig, .gitattributes, .php-cs-fixer.dist.php and its
custom SectionComments fixer, composer.json, the workflows in .github,
DCO.txt, the PR template and compose.yaml.

Two points are worth stating explicitly for tools that cannot infer them:
that there is no test suite, so green CI only means the code parses and is
formatted; and that the sign-off check can pass on a pull request even
when the commits are unsigned, because it walks up to the parents.

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>
@live627

live627 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Separately, the custom SMF/section_comments fixer is not attribute-aware: when the first member of a group carries a PHP attribute, the banner is inserted between the attribute and the declaration

@albertlast

Copy link
Copy Markdown
Collaborator Author

When this and pr #9317 got merged, i would look into creating tests framework with database stuff

albertlast and others added 2 commits July 30, 2026 06:31
The Docker environment gained a MySQL service and made it the default
engine, so the service list in AGENTS.md was describing a stack that no
longer matches: it named PostgreSQL alone, and pointed at a container
called smf-dev-db-1 that no longer exists now that the service is named
postgres. Lists both engines, and switches the examples to
docker compose exec, which addresses services by name and so does not
break again if the project or container naming changes.

Also states that SMF_DB_TYPE defaults to mysql, and how to re-test on the
other engine, because "any raw SQL must work on both" earlier in this
document is otherwise an instruction with no stated way to follow it.

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

# Conflicts:
#	AGENTS.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants