Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2746307
Adds a PHPUnit suite for code that needs no database
albertlast Jul 29, 2026
120f6a9
Merge branch 'fix/action-trait-subclass-instances' into tests/phpunit
albertlast Jul 29, 2026
10dfe94
Merge branch 'fix/createpost-notify-time-offset' into tests/phpunit
albertlast Jul 29, 2026
a142e5c
Runs the unit tests in CI and documents them
albertlast Jul 29, 2026
99ee104
Broadens the unit tests to the rest of the stateless surface
albertlast Jul 29, 2026
560dfb2
Compares URL schemes case insensitively
albertlast Jul 29, 2026
4caec1d
Merge branch 'fix/sapi-memory-return-bytes' into tests/phpunit
albertlast Jul 29, 2026
f9d38cd
Merge branch 'fix/url-is-scheme-case' into tests/phpunit
albertlast Jul 29, 2026
7d656ce
Turns the two noted defects into regression tests
albertlast Jul 29, 2026
3e45ec4
Marks the ActionTrait test as covering a trait
albertlast Jul 29, 2026
fa76555
Merge branch 'docs/agent-instructions' into tests/phpunit
albertlast Jul 30, 2026
b5f342f
Documents when the unit test suite can cover a change
albertlast Jul 31, 2026
64621b1
Merge branch 'release-3.0' into tests/install-cli
albertlast Aug 2, 2026
459b271
Reports maintenance tool failures on the command line
albertlast Aug 2, 2026
11fe20d
Stops the installer assuming there is a web request
albertlast Aug 2, 2026
516f453
Merge branch 'fix/install-finalize-user-not-loaded' into tests/instal…
albertlast Aug 2, 2026
7477a6c
Skips the browser sign-in when installing from the command line
albertlast Aug 2, 2026
89324b1
Reports the step a maintenance tool actually paused on
albertlast Aug 2, 2026
7665045
Installs the forum from the command line
albertlast Aug 2, 2026
7876ac1
Marks the dev environment scripts executable
albertlast Aug 2, 2026
170679c
Merge branch 'tests/phpunit' into tests/integration
albertlast Aug 2, 2026
b79d33c
Adds an integration suite that runs against a real forum
albertlast Aug 2, 2026
241250d
Lets the section comment fixer place its own banners
albertlast Aug 2, 2026
c7b53cd
Removes the trailing tabs from a blank line in PM search
albertlast Aug 2, 2026
61a35f5
Removes install.php once the forum is installed
albertlast Aug 2, 2026
78f54a1
Merge branch 'tests/install-cli' into tests/integration
albertlast Aug 2, 2026
c67166e
Adds HTTP smoke tests that drive a running forum
albertlast Aug 2, 2026
5df86c6
Documents how to write an HTTP test
albertlast Aug 2, 2026
c7b9f5a
Adds a script for checking and resetting account passwords
albertlast Aug 2, 2026
4ed7ed2
Merge branch 'tests/install-cli' into tests/integration
albertlast Aug 2, 2026
f66a6be
Merge branch 'tests/integration' into tests/http
albertlast Aug 2, 2026
218d95f
Points the credentials note at user.sh
albertlast Aug 2, 2026
8c939ce
Merge remote-tracking branch 'origin/release-3.0' into tests/phpunit
albertlast Aug 5, 2026
ebd1d45
Merge branch 'tests/phpunit' into tests/integration
albertlast Aug 5, 2026
6c7690e
Merge branch 'tests/integration' into tests/http
albertlast Aug 5, 2026
bcfcccb
Merges release-3.0 into the unit test branch
albertlast Aug 16, 2026
6e8bf15
Follows TimeInterval back to DateInterval's own constructor
albertlast Aug 16, 2026
1efc72f
Merges the updated Docker environment, and release-3.0 with it
albertlast Aug 16, 2026
b575593
Merges the updated install branch, and release-3.0 with it
albertlast Aug 16, 2026
270bece
Merges the updated unit test branch
albertlast Aug 16, 2026
99ca45c
Merges the updated integration branch, and release-3.0 with it
albertlast Aug 16, 2026
7518d72
Fixes the code style in the guest pages test
albertlast Aug 24, 2026
29017ae
Merges release-3.0 into the command line install branch
albertlast Aug 24, 2026
f2fade7
Merges the command line install branch, and release-3.0 with it
albertlast Aug 24, 2026
b6013cd
Merges the integration test branch, and release-3.0 with it
albertlast Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 286 additions & 2 deletions .docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,284 @@ forum.

## Installing the forum

```sh
.docker/install-forum.sh --engine mysql
.docker/install-forum.sh --engine postgresql
.docker/install-forum.sh --engine both
```

That resets the engine's database and installs a forum into it, with no browser
involved. It takes about a minute. Log in at http://localhost:8080 as
`admin` / `password`.

SMF 3.0's installer is CLI-native: `Maintenance::parseCliArguments()` turns
`--name=value` into `$_POST`, and `Maintenance::execute()` then runs every step
in one process, stopping at the first that still needs input. The script makes
two passes, because `databasePopulation()` always stops the first time even
though it succeeded — it pauses so a human can read its "N duplicate tables
ignored" report, and the form's `pop_done` field is the short-circuit past it.
Passing `pop_done` on the first pass would skip building the schema entirely.

It then deletes `install.php`, which the installer asks for but cannot do
itself — its `?delete` link is a GET, and command line arguments only ever reach
`$_POST`. That matters more than it sounds: while the file is there
`Settings.php` redirects every request back into the installer, and SMF puts a
"MAJOR SECURITY RISK" box on every page it shows an administrator. Reinstalling
still works, because `reset.sh` runs first and does not return until the
entrypoint has staged a fresh copy.

Two flags worth knowing:

- `--force` reinstalls even when a forum is already there. Without it the
script leaves an existing install alone.
- `--pin-secrets` fixes `auth_secret` and `image_proxy_secret` to known values
instead of the random ones `ForumSettings()` generates. Both installs then
differ only in their database, so a login cookie survives `use-engine.sh`.
Dev-only values for a throwaway forum: never reuse them.

### Two forums at once

`--engine both` installs MySQL first and PostgreSQL second, one after the other.
It has to be sequential: `Settings.php` pins a single `$db_type`, and
`Db::load()` hands back the connection it already made, so only one engine can
ever be live in a process.

Both installs are kept. Switch between them with:

```sh
.docker/use-engine.sh postgresql
```

That puts the saved `Settings.php` back and clears `cache/`. No restart is
needed — the entrypoint only writes `Settings.php` when there is not one, so it
leaves whatever is in place alone. The copies live in `.docker/settings/` and
are gitignored.

`reset.sh` is the other half: it empties one engine's database and restages the
installer, discarding that forum. `use-engine.sh` switches between forums,
`reset.sh` throws one away.

## Accounts and passwords

Two forums, each with its own administrator, and a password chosen months ago is
a recipe for an afternoon of hand written SQL. `user.sh` is there so it is not:

```sh
.docker/user.sh list
.docker/user.sh check admin 'password'
.docker/user.sh reset admin 'a new password'
```

`check` exits 0 when SMF would accept the password and 1 when it would not, so
it works in a conditional as well as by eye. It also points out an account that
is not activated, which fails to log in with a correct password and looks
exactly like a wrong one.

`--engine mysql|postgresql` reads the settings `use-engine.sh` saved for that
engine, so the *other* forum can be inspected without switching to it:

```sh
.docker/user.sh check admin 'password' --engine mysql
```

The hashing goes through SMF's own `Security` class rather than being written
here, so what `reset` puts in the table is by construction what `Login2` expects
to find. It clears `passwd_flood` at the same time: SMF locks an account out for
a while after enough wrong guesses, and a fresh password behind a lockout looks
exactly like a password that did not take.

## Running the tests

```sh
.docker/test.sh # both engines
.docker/test.sh --engine postgresql
.docker/test.sh --engine both --filter ModSettings
```

Anything it does not recognise is passed on to PHPUnit. It installs a forum for
an engine that has not got one, and puts the previously active engine back when
it finishes.

Running on both is the point rather than a thoroughness exercise. The counter
regression in `tests/Integration/ModSettingsTest.php` **passes on MySQL with the
bug still in place** and only fails on PostgreSQL, because MySQL coerces text to
a number where PostgreSQL refuses. A suite that only ever sees one engine proves
considerably less than it looks like it does.

The unit suite needs none of this — `composer test` runs everything, and the
integration tests skip themselves when there is no forum to talk to.

Some of the tests sign in, so they need to know the administrator. They default
to what `install-forum.sh` creates (`admin` / `password`); if your forum has
different credentials, export them:

```sh
SMF_ADMIN_USER=admin SMF_ADMIN_PASS='…' .docker/test.sh
```

Getting that wrong makes those tests **skip**, with a message saying so, rather
than fail — a password the suite does not know is a misconfigured forum, not a
regression. `user.sh check admin '…'` settles which it is, and
`user.sh reset admin password` puts a forum installed some other way back on the
credentials the suite expects.

## Writing a test

### Which suite

Three of them, and picking the wrong one is the usual reason a test is harder to
write than it should be:

| Suite | Has | Use it for |
| ------------------------ | -------------------------------------- | ----------------------------- |
| `tests/Unit` | nothing — no database, no request | pure functions, value objects |
| `tests/Integration` | `Db::$db`, `$modSettings`, `User::$me` | anything needing real data |
| `tests/Integration/Http` | all of that, plus a real request | proving a *page* works |

Work down the list and stop at the first that can hold the test. An HTTP test
costs about a second and cannot be rolled back; a unit test costs nothing. The
limits of the unit suite are spelled out in `AGENTS.md`.

Reach for HTTP only when the thing worth proving is in the parts nothing else
touches: the session, the cookies, the security token, the theme and the
templates. `User::setMe()` skips every one of them, which is exactly why the
plain integration tests are cheap.

### The shape of an HTTP test

Four beats: fetch a page, submit a form on it, assert on what came back, assert
nothing was logged.

```php
#[CoversNothing]
class ProfileTest extends HttpTestCase
{
public function testAMemberCanChangeTheirSignature(): void
{
$this->signInAsAdmin();

$form = $this->fetch('?action=profile;area=forumprofile');

$response = $this->submitForm($form, [
'signature' => 'Set by the integration suite.',
'save' => 'Change profile', // the button
], '//form[contains(@action, "area=forumprofile")]');

$this->assertLessThan(400, $response->status, $response->errorText());
$this->assertNoErrorsLogged('saving a signature logged something.' . "\n");
}
}
```

`#[CoversNothing]` is not optional. These cross dozens of classes, so naming one
would be untrue, and `failOnRisky` wants an attribute either way.

Four things that are easy to get wrong:

- **Submit through `submitForm()`, not `HttpClient::submit()`.** Only the former
waits out flood control. `Security::spamProtection()` gives a moderator two
seconds between posts, per IP, and the tests all arrive from the same one far
faster than a person would; without the wait you get a suite that fails about
one run in five for no reproducible reason.
- **Name the button you are pressing.** `formFields()` leaves every button out on
purpose, because the posting form carries both `preview` and `post` and sending
the pair means preview quietly wins — no post, and a perfectly good 200 to show
for it.
- **Clean up whatever you write.** There is no transaction here; see
`HttpTestCase::usesTransaction()` for why one would not help. `PostingTest`
deletes through `Topic::remove()` rather than by hand, so the board and member
counters go back as well.
- **`assertNoErrorsLogged()` is the point of the test**, not a formality. A page
can return exactly the right HTML while logging an undefined index, and that is
the failure mode this whole suite exists to catch.

One thing to rule out before believing a failure: if `install.php` is still in
the board root, SMF puts a "MAJOR SECURITY RISK" box on every page it shows an
administrator. That is an `errorbox`, so it fails `assertLooksLikeAForumPage()`
and turns up in `errorText()` in front of whatever the test was actually looking
at. `install-forum.sh` removes the file once it is done; a forum installed
through the browser needs it deleting by hand.

### Who the request is

The identity of an HTTP request is the cookie jar and nothing else. There are two
states out of the box: a guest, which is what `setUp()` leaves you, and the
administrator, through `signInAsAdmin()`.

**`actingAs()` does not work here.** It is inherited from `IntegrationTestCase`
and it repoints `User::$me` in the PHPUnit process — but the request is handled
by Apache in a different process, which knows only the cookie. Calling it in an
HTTP test changes nothing about the request and leaves the assertions describing
a guest, confidently.

So:

- **Two users at once** means two `HttpClient` instances. Each opens its own
cookie jar, so they are independent browsers — which is how to test one member
sending another a PM.
- **Back to being a guest** is `$this->http->forgetCookies()`, then
`$this->http->get('')` to pick up a fresh session.
- **A member who is not the administrator** has to be made first, through
`Register2::registerMember()` with `interface => 'admin'` (which needs
`actingAs($this->adminId())` first, as it checks `moderate_forum`). That member
outlives the test, so delete it in `tearDown()`.

### Finding the endpoint and the field names

Endpoints are looked up; field names are not.

`Forum::$actions` in `Sources/Forum.php` is the authoritative list of every
`?action=` the forum answers and the class behind it. Sub-actions — the `;area=`
and `;sa=` parts — are a `$subactions` property on that class. So
`?action=profile;area=forumprofile` resolves as `$actions['profile']` →
`Actions\Profile\Main` → its `$subactions`. That is quicker and more reliable
than reading templates.

Field names you are deliberately not meant to know. `HttpClient::submit()`
scrapes every input, textarea and select out of the form it was handed and sends
them back, the way a browser does. That is what carries the session check and the
security token, both named unpredictably per session and neither hardcodable. All
a test supplies is the few values it is choosing, plus the button.

When you do need to see them, ask the page rather than the template:

```sh
docker compose exec web php -r '
require "tests/bootstrap.php";
$c = new SMF\Tests\Support\HttpClient();
$c->get("");
$p = $c->get("?action=login");
print_r($p->formFields("//form[contains(@action, \"login2\")]"));'
```

```
Array
(
[user] =>
[passwrd] =>
[d0004e1655] => b2f5189a9b3014cadee7bcb0b8d697f2
[b8ae8fd32d] => 8f6026ed9a1b91bf6fec315801a2a93c
)
```

Two named fields, which are the ones a test writes, and two whose names are
different for every session — the session check and the security token, and
running the command twice gives two different pairs. That is what
`submit()` is for, and why a test that builds its own POST body by hand gets a
403 it cannot fix.

The paths are relative because the container's working directory is
`/var/www/html` already. Spelling them absolutely also works, but not from Git
Bash on Windows, which rewrites anything that looks like a Unix path before
Docker sees it.

Note the throwaway `get("")` before the form is fetched. The very first request
of a new session regenerates it, so a token minted on the first page a visitor
ever sees is bound to a session that no longer exists by the time it comes back.
The symptom is a 403 about the token, when the token was never the problem.

### Installing in a browser instead

On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen
engine and copies `other/install.php` to the web root, so
http://localhost:8080 redirects into the installer.
Expand Down Expand Up @@ -102,8 +380,8 @@ The repository is bind-mounted at `/var/www/html`, so edits on the host are
live on the next request. Opcache is on but revalidates every request, so you
never need to restart for a PHP change.

To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and
`Settings_bak.php`, then `docker compose up -d`.
To reinstall from scratch: `.docker/install-forum.sh --engine mysql --force`.
To wipe everything including the volumes: `docker compose down -v`.

## Debugging SQL with the PostgreSQL log

Expand Down Expand Up @@ -170,4 +448,10 @@ compose.yaml the stack
.docker/mysql/init/10-smf.sh runs once on first mysql database creation
.docker/postgres/init/10-smf.sh runs once on first postgres database creation
.docker/env.example optional overrides

.docker/lib.sh paths, credentials and engine names, shared
.docker/install-forum.sh install a forum with no browser involved
.docker/reset.sh empty one engine and restage the installer
.docker/use-engine.sh switch which installed forum is live
.docker/user.sh inspect accounts, check and reset passwords
```
Loading
Loading